diff --git a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md index fa1b3ece7f..3b8594cd73 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md +++ b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md @@ -43,6 +43,8 @@ Rust Core 负责校验声明、识别工程、展开每一条命令行,并把 没有声明的能力不报错,也不会出现在界面上。例如 Cargo fixture 没有声明依赖树,Build 工具窗口就不显示依赖区。 +`build.detect` 对普通文件根或不存在的根保留空实例结果,这属于检测入口的既有兼容边界。共用工作区扫描器后仍在此入口区分根的类型;真实目录的读取、权限或扫描失败继续返回拒绝,不能把所有 I/O 错误当作“未检测到项目”。不要为了修复 Windows 的非目录错误而放宽共用扫描器,也不要让宿主按操作系统错误码猜测空结果。 + 字段全集只写在 `shared/contracts/plugins/v1/README.md` 的 “API level 3: build systems” 一节和 `plugin-manifest.schema.json` 里,本 Note 不重复。四个 fixture 各示范一种模型来源,按能力命名而不是按真实插件命名:`build-static-file`(读项目文件,内容用 Cargo)、`build-command-stdout`(解码命令输出,内容用 Go)、`build-command-output-file`(解码命令写出的文件,内容用 Gradle)、`build-host-integration`(交给宿主集成,内容用 Maven)。`build-provider`、`build-provider-language-server` 分别示范 Provider 的两种写法。 ### 2. Core 与宿主的分工 diff --git a/.agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md b/.agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md index 7f19d6e5b7..9505f767ef 100644 --- a/.agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md +++ b/.agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md @@ -160,6 +160,12 @@ Swift 和 Windows 各写一遍合并规则最直接,但两边一旦有细微 - 代价:项目设置文件会出现在用户仓库里,团队需要决定是否提交它。 - 现有的 Java/Maven 项目设置页(`ProjectRuntimeSettingsView`、`LSPControlCenterView` 中的 Maven 重试)仍是宿主代码,属于历史遗留,后续应迁移到 Java 插件的声明里,不能作为新增特判的先例。 +Windows 设置验收使用独立 API3 受控插件,而不是删减正式包的 requires。受控包包含既有 LSP fixture,因此必须披露语言服务器权限和完整程序声明,并把获批声明及归档摘要与安装记录逐项比较。注册语言设置页与真实双窗口覆盖保存、空值、重置、字节冲突、项目及全局重载、升级停用卸载;这些受控检查不能替代正式发布包或插件函数验收。 + +验收观察器适配锁定 Tauri 的 Windows fetch IPC 与真实 callback Map,不改写被冻结的 invoke,不替换原生命令或回复。收尾先恢复 fetch,随后等待已接纳回调有界排空,再正常退出宿主,最后比较运行镜像和插件资源;未退出、未排空、丢失调用或资源变化都不能报告通过。postMessage 回退传输未覆盖,仍是待验范围。 + +公共搜索清空的产品验收从已开放的插件设置列表进入,保留明确选中的已安装包与原始行身份。通过浏览器输入通道发送 Enter/Space down/up,观察同一输入节点重新获得焦点、零匹配过滤恢复、原选中项和详情不变;不使用合成 DOM 键盘事件或补发 click。该操作应没有声明式插件 IPC。测试失败也保留部分快照、释放元素句柄,并继续执行观察器、原生进程及资源快照的收尾。CDP 输入不能证明物理键盘或真实输入法。 + ## 验证 - `cargo test -p lithe-core --lib settings_v2`:清单校验、分层合并与 `workspaceConfiguration` 展开。 diff --git a/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md index 14cca53000..6dd22b0239 100644 --- a/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md +++ b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md @@ -31,7 +31,8 @@ Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模 - 安装根为 `app_local_data_dir()/plugins///`,`installation.json` 记录版本与启用状态;记录缺失或损坏的包隔离展示,绝不静默加载。 - bundled 包从 `resource_dir()/OfficialPlugins` 只读加载,多版本目录取最高版本;运行时不写安装目录与资源目录,遵守发行基线只读边界。 - 每个插件的语言服务器状态目录在 `app_local_data_dir()/plugin-state//`,由宿主注入启动计划请求。 -- 信任模型 v0:官方 ID 防冒用由 Core 校验兜底;bundled 包随安装器分发;磁盘导入(已落地)与市场下载(M3+)一律走 `plugins.marketplace.installReview` 全量审批(权限 + 完整程序清单),审批界面明示"未认证发布者"。publisher Ed25519 验签明确延后,这是 v0 的已知缺口,Windows 验签方案(Authenticode 还是包内 Ed25519)单独立项评估。 +- 信任模型 v1(Windows 插件对齐路线图 M1,见 `docs/development/windows-plugin-parity-plan.md`):发布者 Ed25519 验签已接入 `plugin_host/signature.rs`,与 macOS `PluginPackageSignature` 字节兼容(canonical payload、UTF-8 base64url token、按原始路径排序、每文件 SHA-256 与 contentDigest),采用成熟的 `ed25519-dalek`,固定官方公钥 `lithe-official-plugins-v2`;生产私钥不进入仓库。官方 ID 的信任策略由宿主固定(`php/java-support=sameTeamAsHost`、`go-support=publisherPackage`),覆盖包自声明;`sameTeamAsHost` 只信任宿主安装资源内的 bundled 包,磁盘/市场包不能凭目录名获得该身份。签名文档存在即必须验证,坏签名不会被忽略。磁盘导入与市场下载(M2+)仍走 `plugins.marketplace.installReview` 全量审批(权限 + 完整程序清单),审批界面明示"未认证发布者"。 +- 安装记录 schema 2 是安装事务的一部分:`origin`、marketplace/disk provenance(全包内容摘要与批准程序的 canonical JSON)、上一版本的 provenance 都随版本发布一起写入;安装确认、staging 发布前、启动扫描、运行/测试计划入口与回滚都会重新校验完整文件集合和摘要(community 与 marketplace origin 都复检已批准摘要,签名只证明发布者、不豁免字节比对)。版本激活与记录提交是同一事务:记录先写入 temp、激活成功后才提交,提交失败恢复旧字节并保留旧记录,恢复失败如实报错。schema 1 旧记录没有 provenance,扫描直接隔离(`plugin.trust.provenanceMissing`)并要求重新安装,绝不凭 `enabled` 或清单摘要补造发布者身份和已批准字节。bundled 身份只能由可信资源发现器产生:installed 记录声明 `origin`/`previousOrigin=bundled` 一律隔离,`sameTeamAsHost` 在扫描、启动与回滚路径都校验真实资源根包含,记录声明与回滚候选同样拒绝。审批绑定 prepare 时的整包字节:prepare 冻结整包摘要、清单摘要与审查程序,confirm 与 staging 对同一审核身份校验,prepare 之后替换/新增/删除文件都被拒绝。磁盘渠道接受已验签的 `publisherPackage` 包(origin 保持真实 disk/community),声明 `sameTeamAsHost` 的磁盘包即使签名有效也拒绝。 - 磁盘导入的私有快照、staging 重验、内容摘要复查与回滚语义照抄 macOS 侧规则(见声明式语言插件 Note 的磁盘安装节),Windows 不另造审查语义。 ### 启动接线(M1 落地形状) @@ -42,6 +43,22 @@ Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模 - 语言服务器进程仍由共享 Core 的 `lsp.startServer` 拉起:前端 `resolve-editor-lsp-launch.ts` 在 Worker 扩展清单没有对应服务器时,回落到 `declarative_plugins_language_launch`(契约插件排在 Worker 之后,已由 Worker 声明的语言保持现状,直到 M3 包迁移)。 - 启动扫描在应用 bootstrap 的 "declarative plugins" 步骤执行一次;扫描结果缓存在 Tauri state,前端经 `declarative_plugins_state` 只读消费。 +### 设置保存与作用域重载 + +设置值的校验、默认值、全局与项目覆盖、显式空值和继承重置仍由 Core 解释。Windows 的设置页面只保留草稿并显式保存,写回后重读字节身份;`immediate` 推送匹配语言会话的配置,`restart`、`nextRun` 等待后续启动或运行,`reload` 与 macOS 一样重新读取构建模型。保存不代表已经完成重载,用户须另行确认。 + +全局 reload 沿用插件级跨窗口停止屏障,先撤销贡献并等待全部旧使用者退出。项目 reload 只暂停当前产品窗口、工作区和目标插件的新调用,等待其旧构建操作、设置与网页函数、原生面板函数及 VCS 会话完成清理;其他插件和其他窗口继续有效,也不改持久启用状态。原生项目登记号与真实 Tauri 窗口资源的一次实例,连同保存后的设置字节身份、已接受包身份绑定重载;复用窗口标签或项目路径不能继承旧凭据。 + +设置验收驱动使用独立的 settings-only API3 受控包,避免用尚未开放的函数能力冒充设置验收。双窗口保存、项目级 CAS(比较设置修订后再写入)、reload、生命周期和发行资源哈希检查由同一驱动编排;reload 保存必须从注册的语言设置页进入,编辑器只在当前可见内容中定位。输入计划、隔离宿主和当前检出的源码必须具有相同提交身份,否则预检拒绝继续。正确做法是先生成当前源码的受控包与构建身份,再执行对应子集;不要拿旧构建、旧计划或仅有辅助测试的结果宣称完整包已验收。函数能力、真实签名完整包以及清理失败注入的缺口仍使完整预检非零退出,不能通过放开生产门控消除这些缺口。 + +局部检测仍把完整插件集合交给 Core 决定构建系统归属,然后只重读、合并目标插件的模型与诊断。其他插件和其他窗口的代次、模型修订及执行上下文保持有效;目标修订继续递增,旧操作不能因重读同一内容而重新获准发布。命令或 Provider 模型复用既有模型准备、代码执行审批、运行、取消及清理流程,每次开始和发布都复查保存凭据,不另建 Provider 运行时。 + +项目级屏障在原生 registry 锁内先推进该插件的私有设置修订并捕获原构建操作,清理只处理捕获的实例;屏障结束后仍保留修订,使旧 prepare、网页审批和 VCS 读取不能迟到复活。VCS 的修订查询不要求存在构建模型,纯 VCS 工作区保持可用。原生预检查和排空不可取消,界面保持忙碌;进入模型准备和审批后才提供取消,已经取消的调用不能再开始模型准备。 + +停止失败时保留原生操作及其重试入口,重试先排空旧操作,再检查保存后的设置与包。原生 Provider 清理复用显式取消的初始化排空与最终清理流程,失败后重试确实再次清理原实例,不能只重复等待一个永远不会完成的标记。模型服务清理失败时,重载入口只保留旧操作 ID,并通过既有取消入口重试完整清理后继续;不能把收到取消请求视为清理完成。审批窗口卸载会拒绝未决审批,取消等待清理完成,迟到的响应不能恢复已关闭或同路径重新打开的项目。 + +正确做法:项目 A 保存 reload 设置后,只重读 A 中该插件的模型;若清理失败,保留通知并重试。不要用全局 deactivate/activate 实现项目 reload,也不要在前端过滤窗口后宣称已有原生隔离。该流程不增加运行时资源目录,继续使用既有平台存储位置,不写发行包、安装资源或共享工作树缓存。 + ### 网页与版本探测的运行边界 Tauri 把插件自定义协议页面视为远端来源,空 capability 会在进入 Rust handler 前拒绝所有 IPC。宿主采用 Tauri 官方 inlined plugin(应用内命令插件)扩展点:`declarative-web` 命名空间只声明一个 `request` 权限,仅匹配 `plugin-web-*` 窗口和 `http://lithe-plugin.*/*` 来源;不授予 fs/http/event 等命令,也不启用会改变全部主窗口命令的 app ACL。编译后的 Tauri authority 回归验证这条边界,不能用只检查 handler 的测试代替。 @@ -74,6 +91,42 @@ WebView2 的 NavigationStarting 可以阻止外站文档提交,但实测外站 `ui:1` 的文本工具窗口由宿主生成转义后的 HTML,`ui.commands` 接入已有的命令、菜单和工具栏展示注册表,工具窗口也有活动栏入口。复用展示注册表不等于进入 Worker 执行模型。禁用时同步撤销入口,异步刷新按插件检查版本,避免撤销 B 时取消 A 的刷新,防止旧结果恢复入口。目前页面仍是独立 WebView 窗口;声明的停靠位置、图标、动作可用/勾选状态和布局持久化尚未与 macOS 对齐,因此矩阵继续记为 partial,不能称为完整的 ui 1/2 产品形态。 +### 网页函数、原生面板与只读 VCS 的增量边界 + +网页的 `functions.list/invoke/cancel` 复用设置动作已经使用的独立 Core 会话,不新建进程或 JSON-RPC 管理器。原生登记的页面、产品窗口、工作区、构建实例和完整包摘要共同决定可调用范围;页面传入的实例名只用于查找这组登记,不能授予执行程序或其他插件会话的权限。审批由产品窗口处理,但调用属于独立插件页面,确认时必须分别验证这两种身份。页面得到正式契约的 `{functions}` 或 `{value}`,设置动作仍消费已有快照,不把两种返回格式混在一起。 + +等待审批就占用调用名额。取消、导航、隐藏、关闭、停用和替包必须先撤销页面调用,旧确认与旧进度不能触发新页面。原生启动或销毁迟到时,原会话继续承担有界清理并保存成功证明;只有证明原进程已经结束,才能释放它的登记。正确做法是让迟到完成回收自己持有的原会话;不要用可复用的窗口标签删除一个后来创建的替代会话。设置动作的完成凭据绑定 Tauri 原生窗口资源的一次实例,最多保留 4096 条,窗口销毁时同步撤销该实例并释放其凭据,再异步清理捕获的原会话;新窗口即使复用标签也不能继承旧取消权限。拒绝审批和准备失败也必须结束页面请求,不能留下等到总超时的 Promise;取消与失败通过同一终态锁按先到事件决定结果。 + +网页审批结束后,清理失败的调用仍要保留在产品窗口可见的重试列表,不能只写控制台或要求用户停用插件。重试绑定原操作和原生窗口实例;成功释放后才移除记录,迟到的失败通知不能重新占有已释放调用。窗口关闭后的原生清理继续持有原会话,不能让复用标签的新窗口接管旧调用。 + +`native-ui/` 承载 `ui:3` schema 1 的树、列表、表单展示,会话的数据校验仍交 `plugins.nativeUI.validateData`。生产工具窗口把显式类型函数接口接到设置与网页已有的独立 Core 会话 owner(负责持有与回收进程的对象)。挂载先从原生 catalog 登记精确面板句柄,绑定包摘要、原生窗口实例、工作区代次和唯一检测到的同插件构建实例;函数名、Provider 和程序计划只读取可信声明。前端只交动作 ID、选择和表单值,准备前由 Core 校验全部当前字段,加载结果仍交 Core validateData。缺少唯一 dedicated Provider 或协议能力时明确不可用,ui:3 准入继续关闭。面板状态只放内存,按完整包、产品窗口、工作区和面板身份隔离,关闭或撤销时取消请求;同一插件、产品窗口和工作区的所有面板累计受 64 KiB 预算约束,不能每开一个面板就获得一份独立预算。长列表复用虚拟化。动作前先验证当前草稿,外部重载和重置提升会话版本,旧失焦回调不能恢复旧草稿;数据源短暂失败后允许重试,保留用户草稿和选择。这些状态不写插件包、安装资源或共享缓存,不影响签名与增量更新基线。等待原生准备和审批也占用面板唯一名额;确认、启动与交付结果前复验原登记和构建实例版本。隐藏重开、同槽替换及迟到释放只关闭其捕获的句柄,不按可复用窗口标签关闭新 owner。清理失败继续由全局函数提示持有原 receipt(完成凭据),保持 exact Window incarnation(原生窗口的这一次实例)与重试证明;已证明完成的旧释放不会清空下一次调用的占位。进度、错误和动作消息沿现有函数结果返回。 + +原生面板的打开请求在第一个异步步骤前取得顺序号;较早请求即使较晚完成,也不能覆盖同槽已经登记的新面板。构建模型或设置重载让成功登记的旧句柄失效后,用户重试必须先成功关闭该精确句柄,再重新登记;关闭失败仍保留旧句柄,不能通过新建会话跳过清理。 + +VCS 的历史、文件历史和基线比较只扩展已有短会话,继续使用 Core 的 `ownerOf`、请求和解码器,不自行运行客户端或解释版本控制元数据。游标和顺序由 Provider 决定;根映射、目录身份、包摘要或监听版本改变时,取消原查询并拒绝旧页发布。新增文件的基线和删除文件的当前内容允许为空,比较时分别作为空文本;重命名保留原路径。二进制内容明确提示不可比较。停止失败保留原操作和可见的重试停止入口,不能让禁用按钮把清理永久锁住。R11 只读范围之后,写操作按下节的精确确认边界接入;`vcsProviders` 继续关闭。 + +### 精确状态确认后的 VCS 写操作 + +选中提交、更新、回滚、标记冲突已解决(resolve)、清理工作副本(cleanup)及添加/删除文件继续使用短会话与 Core 的正式类型操作,不运行客户端命令或解析输出。原生状态凭据只在内存中保存,绑定窗口、工作区 incarnation、注册代次、根目录原生 identity、插件和完整包摘要;每个窗口/根只保留最新凭据,总条数有界。确认界面展示根路径、全部目标、双端路径数量、提交原文和操作风险,取消确认不会启动会话。执行命令消耗一次性凭据,不能靠重按确认或重试停止再次提交。 + +确认后先重新读取 Provider 的完整状态,和确认使用的原始标准化结果及协商能力比较;状态有任何变化就拒绝本次写入。最终请求前再次验证实时包签名、安装审批、工作区注册、根目录和 Core 的最深归属。选中写入的每个端点必须能证明是普通文件;共享状态没有文件类型字段,因此消失的端点不能仅凭 missing/deleted 状态当作文件接受。提交或 resolve 选中子文件若处于 added/replaced/renamed/copied 状态的祖先下,也必须拒绝,避免 Provider 隐式扩大确认列表。提交和回滚的重命名必须同时验证两个端点,无法证明其中一个时保守拒绝。目录递归写入无法证明精确选择时拒绝;update 和 cleanup v1 都没有路径参数,因此只允许工作区等于整个根、根检测和状态完整且不存在嵌套或歧义根,再由 Core 确认整根归属,不把父工作副本的其它目录一起操作。 + +resolve 使用实际协商的 `conflicts` 能力,只接受状态为 conflicted 或带 treeConflict 标记的现存普通文件;目录、消失端点、路径别名及无法证明精确归属的目标均拒绝。确认逐项展示所选冲突路径,发送时只保留这些路径,不展开重命名的 oldPath;操作使用用户已经保存的内容并改变冲突元数据。cleanup 使用协商的 `cleanup` 能力,确认说明整根范围和锁等元数据影响,类型参数保持为空,不能附加路径或版本冒充局部清理。 + +添加和删除复用 Core 的 `fileOperation` 请求与协商的 `fileOperations` 能力。add 只支持根目录直属、状态为 unversioned 的现存普通文件;v1 不能证明未出现在状态列表的父目录已受版本控制,因此嵌套文件添加也保守拒绝。delete 必须展示全部端点,并说明磁盘删除由 Provider 执行、请求没有 keep-local 选项且宿主不备份;所选行及展开后有独立状态的端点都须在允许范围内,冲突、忽略、未受控、外部或子模块端点不能随重命名扩展混入。目录、缺失端点、隐式 scheduled 祖先和路径别名仍按原规则拒绝。 + +正确做法:用户选中已存在的普通文件 `after.txt`,确认展示全部请求端点,原生复核类型、祖先状态及同根归属后才发送一次 checkin。不要这样做:把已消失的目录当作一个文件回滚,隐式提交 added 祖先,或将工作区内部的 update 当作能限制范围的请求。缺失文件和常见重命名的写操作保留为后续能力,不能为扩大支持而猜测节点类型。 + +写入开始后的本地取消、丢失回复、非法回复或成功回复后的清理失败可能已有副作用,界面要求刷新核对;明确的认证失败、过期提交或写前拒绝保留失败状态,不混成未知结果。Core 在请求完成事件中交付的显式 Provider 失败也是确定失败,传输中断仍属于未知结果。此类确定失败即使同时遇到销毁失败,也保留原错误及其分类,后续停止重试成功后仍显示原失败;不能用清理错误覆盖写入是否发生的事实。停止重试只使用原操作的进程归属,不能重发 checkin/update/rollback/resolve/cleanup/add/delete。不同窗口对同一根的写操作互斥。原生进程树结束证明仍由既有 Core 会话清理提供,失败保留租约与原 owner。 + +写入自身会改变元数据。最终复验成功后,在原窗口、scope 和 generation 的 registry 条目中暂停监听失效,直到写后完整检测替换该条目;条目数量沿用工作区预算。这样进程 owner 已回收后才交付的旧元数据事件,也不能撤销成功结果。其它窗口继续独立失效,插件撤销和窗口关闭仍直接撤销 registry。完成后由原 scope 重读根、同根状态和原历史;新一代恢复普通监听,旧 scope 不发布结果。监听永远不确认或补发写操作。 + +文件移动不能沿用已存在双端点的校验后直接执行。正式 v1 的 `fileOperations:1` 只声明操作可用,不承诺原子地拒绝覆盖,也不保证缺失叶子不会被解释为后来出现的目录;最后一次目标不存在预检无法消除 Provider 调用期间的竞态。固定 Subversion Provider v0.1.0(commit `3a558be049f47a3ebe24ae770faf7999df883c29`,SDK `54598ae6e0b9fd71a07b77e1fea6917ba8897a6f`)把已跟踪移动交给 `svn move --parents`;没有 `--force` 不是并发安全证明。当前 Windows 不增加 move 菜单、未接线的计划器或磁盘 fallback。保证应由 Provider 与成熟 SVN 事务实现提供,契约如何表达该保证须先评审,不能按插件 ID 特判或重新解释既有 v1 能力。源码审查和当前控制器拒绝不证明真实 Provider、安全目标发布或固定 Windows 包已验收。 + +Windows x64 对上述固定 Provider 源码和历史 SDK 的直接 stdio 诊断,使用 VisualSVN 官方签名的 Apache Subversion 1.15.0 复现了这个边界。事件门位于真实 status 完成及 handler 进入之后、最终 SVN 启动之前;诊断 wrapper 原样转发参数与输出,不属于发布包,Provider 已跟踪 move 分支本身没有目标预检。目标始终缺失时,源移到精确叶子;目标随后出现文件时返回 E155033,字节和文件身份未变,但 `--parents` 已把目标调度为 added;目标随后出现目录时返回成功,源实际移入 `target.txt/source.txt`。因此失败也不能假定工作副本无副作用,成功也不能假定目标是精确叶子。三例只证明诊断设备完成并给出反例,不证明生产宿主或正式包验收;当前一次性脚本还缺少长期 CI 所需的外层 watchdog 和异常清理,不能直接注册为长期回归。 + +新增状态全部在内存中;Provider 可写目录仍由现有平台存储 adapter 提供,没有新下载、生成或缓存路径,安装包和发行资源保持只读。`ui:3` 与 `vcsProviders` 继续关闭;文件 move、缺失节点类型证明与 gutter/Annotate 未扩展。resolve/cleanup 已接入不代表真实 SVN 客户端、生产插件包、原生进程树或 WebView2 人工验收通过,这些验证继续 pending。 + ### 与 Worker 扩展模型共存(产品分工) - 两套模型互不桥接:契约插件不进 Worker 宿主,Worker 扩展不冒充契约能力。 @@ -81,16 +134,47 @@ WebView2 的 NavigationStarting 可以阻止外站文档提交,但实测外站 - `install_extension_from_url` 等 Worker 非语言市场命令(M2 已处置):在 Rust 宿主注册为显式占位错误(`extensionStore.notAvailable`),调用方得到可诊断的结构化失败而不是"命令不存在";完整落盘后端是独立工作项,不与契约宿主混淆。 - 管理界面按模型分组展示两类扩展并使用不同文案,避免用户把 Worker 扩展当成契约插件。 +### Windows 构建依赖的来源与代理边界 + +根 Bun 工作区的显式 tarball 来源改用 npm 官方仓库,保留全部精确版本、依赖声明和 +SRI(压缩包的完整性摘要)。来源迁移必须机器比较完整 lock 图;只传 `--registry` +不能证明显式 URL 已改变。Bun 缓存仍以工具版本、lock 原字节和文件清单封存,来源 +变化会使旧 seal 失效;安装目录、临时目录和 GitHub lifecycle 二进制不能跨树复制。 +工具版本固定为 Bun 1.3.14:该版本官方说明明确修复安装时 TLS 握手停滞、下载 HTTP +错误及 isolated linker 摘要失败后的挂起;1.3.12 实测停在 GitHub 归档路径,而 +1.3.14 同一归档能进入原生构建。此处升级的是安装工具,不升级产品依赖图。 + +tree-sitter-cli 的两个上游 `install.js` 保持原样。Windows worker 只为该包加载 +`windows-tree-sitter-proxy.cjs`:有 HTTPS 代理时,把连接管理交给 Node 原生 +`https.Agent({proxyEnv})`,并仅在当前 lifecycle 中移除上游手动 CONNECT 分支读取的 +环境变量;TLS 校验、重定向、响应解压和可执行文件落盘仍由 Node 与上游实现。 +正确做法是验证完整响应体、gzip 解压及 `--version` 后再运行 frozen 全量安装; +不要因响应头为 200、增加超时或手工放置 EXE 就宣称修复。代理适配要求支持该能力的 +Node 22.21+ 或 24.5+,不新增下载器和长期二进制缓存。代价是代理安装的 Node 最低版本 +更明确;收益是复用上游原生连接管理,避免维护第二套 CONNECT/TLS 实现。 +代理选择遵循 Node 的 `https_proxy || HTTPS_PROXY` 优先级;不支持的协议、非法地址或 +凭据编码必须在替换 Agent 和删除环境变量前脱敏失败,不能让 Node 忽略配置后静默直连。 +复制安装 worker 的测试夹具也必须复制该 preload,不能只在正式构建准备夹具中登记。 +安装回归在 locker 子进程确认锁定前握手并保留原生进程句柄及创建身份;等待和失败清理 +都使用原句柄,不在 worker 退出后按历史 PID 重新查找或终止进程。等待线程退出后才关 +共享句柄,临时工具、身份凭据、服务和连接均随本 case 清理,不作为资源缓存复用。 +源码清单先解析模块的真实路径,再相对已解析的仓库根生成键;SUBST 导入路径和物理 +路径必须得到同一清单,不能因开发者用短路径避开 Windows 编译路径上限而拒绝构建。 + ### 实施切分 -M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁盘安装链路 + 管理界面 v1 + 死链处置(已落地);M3 声明式设置 + 运行/测试计划 + PHP 契约包(已落地,Worker 版保留见上文);M4 Web 工具窗口(已落地,桥 v0 只含 state/openFile/主题,build.* 与 functions.* 等 Windows 构建系统能力接入后开放)。每个里程碑独立 PR,矩阵条目随代码同 PR 更新。总体方向遵循[公共扩展契约与 Provider 接入方向](../../proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md),本 Note 只落地其中 Windows 契约宿主的第一阶段。 +M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁盘安装链路 + 管理界面 v1 + 死链处置(已落地);M3 声明式设置 + 运行/测试计划 + PHP 契约包(已落地,Worker 版保留见上文);M4 Web 工具窗口(已落地,桥 v0 只含 state/openFile/主题,build.* 与 functions.* 等 Windows 构建系统能力接入后开放)。路线图 M1(发布者签名 + 全包完整性 + 安装记录事务,`signature.rs` 与 schema 2 安装记录)已落地;本 Note 早期的 M1–M4 编号是宿主 PR 的内部切分,与路线图的 M0–M10 不是同一序列。每个里程碑独立 PR,矩阵条目随代码同 PR 更新。总体方向遵循[公共扩展契约与 Provider 接入方向](../../proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md),本 Note 只落地其中 Windows 契约宿主的第一阶段。 ## 考虑过的备选方案 +- 仅扩大安装预算、反复冷装或自建 tree-sitter 二进制缓存:不能消除实际 TLS 路径差异, + npm SRI 也不认证另行下载的 GitHub 二进制;尤其官方摘要为 null 时不能建立可靠复用身份。 + 采用官方 npm 来源与 Node 原生 Agent 的最小适配,保留上游生命周期。 + - 把 Worker 扩展模型整体改造成契约宿主:契约没有主题、AI provider 这些概念,改造成本高且无收益,不采用。 - 在 Tauri 侧另写一套清单校验:违反"校验只在 Core 实现一次",共享 fixtures 无法复用,双端语义必然漂移,不采用。 - 把 Worker RPC 桥接成 litheFunctions:两者协议语义不同,会同时存在两套函数协议,等有真实插件需求时再按[插件函数契约](2026-10-20-plugin-function-contract.md)接入。 -- 信任模型一步到位 publisher 签名:Windows 验签载体需要单独评估,v0 以 bundled 加全量审批兜底,签名延后是显式记录的缺口而不是遗漏。 +- 信任模型一步到位 publisher 签名:v0 曾以 bundled 加全量审批兜底,签名延后是当时显式记录的缺口;路线图 M1 已选择与 macOS 相同的包内 Ed25519 文档签名(而非 Authenticode),因为它跨平台字节兼容、验证在安装事务内完成,且不依赖系统证书存储。 - 在 `lsp_start` 一类原生命令里拉起插件服务器:现有 LSP 会话全部由 Core 统一管理(会话、取消、事件泵都在 Core),前端只解析可执行路径与参数;另建原生拉起路径会分裂会话所有权,不采用。 ## 后果 @@ -100,8 +184,41 @@ M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁 - 已知风险:签名延后意味着 v0 社区包信任完全依赖审批界面(M2),文案必须明示未认证状态;Windows 的 reparse point/junction 语义与 macOS symlink 不完全相同,包内路径逃逸判断已按 Windows 语义单独测试;WebView2 对自定义 scheme 与 CSP 的实现细节与 WKWebView 有差异,`ui:2` 网页链路已用 Windows Release/WebView2 实机验证,停靠、图标、动作状态及布局仍为 partial。 - M2–M4 里程碑:磁盘/市场安装链路 + 管理界面;声明式设置 + 运行/测试计划 + PHP 契约包替换;Web 工具窗口。每个里程碑独立 PR,能力声明与矩阵条目随代码同 PR 更新。 +显式 SVN move 诊断通过 vcs_move_diagnostic.py 接收固定源码、SDK、SVN 和 gate 的绝对输入,先解析真实路径并拒绝输出与输入树重叠(含 junction 别名),再创建独立临时 runtime/repo/WC/profile。每个工作进程先归属 Windows Job 再释放启动,case/suite 有硬 deadline,退出核实零成员后才允许恢复快照;清理无证明时记录 unknown、隔离保留临时树,禁止继续 SVN 读取或删除。最终固定输入/runtime 字节变化或读取失败必须写入失败报告,不能仅让 CLI 非零却留下绿色 JUnit。离线回归验证这些边界,真实三例仅记录诊断匹配且 safetyPass 始终 false;不启用生产 move,不把 wrapper gate 当作 Provider 内部预检。临时状态及证据通过既有 plugin-host-acceptance-state 排除路由拒绝跨工作树复用。 + +ui:2 网页桥的回退验收保持生产 transport(传输入口)原样:受控静态 unsigned 包仅声明 ui.contribute/ui.web,从真实安装审查和插件窗口入口进入私有 WebView2。故障只在该页 CDP(浏览器调试协议)session 中令一个精确匹配 ui.setState nonce 的 custom-protocol POST 失败;随后原 Promise 经真实 postMessage 成功,保持拦截时 ui.getState 读回 nonce 且 POST 总数仍为一次,证明该文档后续请求沿用回退路径。观察固定 Tauri warning、冻结入口描述符/函数摘要和真实 callback Map 排空;不得替换 fetch/invoke/ipc、伪造 callback 或补回包。失败也释放 paused 请求、关闭 CDP 和产品、按进程句柄清理隔离 profile,最后核对发行镜像、安装包与 ZIP 字节。这只证明 Windows x64 下受控网络故障的 ui:2 状态桥;不证明物理网络故障、其他能力、arm64 或正式签名完整包,M10 仍 partial/pending。 + +Apache Subversion 1.15.0 官方源码(commit e49d66ef91dd33409c7d78f82160d2976a939120,SVN r1938480)的 move7 在单源、父目录命名空间稳定且四项布尔参数均为 FALSE 时,保留请求目标路径并关闭移入已有目录的重试;这只约束路径解释,不保证目标身份或禁止替换。目标检查先于工作副本写锁,写锁只约束遵守 SVN 协议的重叠写入,不能阻止普通文件系统外部写入。目标元数据复制、磁盘重命名、源元数据删除分阶段执行;WIN32 最终直接调用带 MOVEFILE_REPLACE_EXISTING 的 MoveFileExW,因而目标可能被替换,失败或取消也可能已有磁盘/元数据副作用。APR rename 不在该最终路径上,无需猜测 APR 版本;官方源码静态分析不证明 VisualSVN 二进制身份或真实竞态,不能作为 Windows move 准入依据。引用提取必须按 LF 物理行计数,不能把 C 源码中的换页符当作新行。 + +ui:1 字面文本安全验收使用一次调用生成的完整 unsigned API3 包,仅声明 ui.contribute;计数服务先绑定 loopback 端口并做正向控制,再生成 ZIP 和绑定当前源码身份的 plan。正式入口必须冻结构建前后源码清单并绑定可执行文件、配置和提交,不能给旧二进制补造当前 identity。打开私有页面前订阅浏览器请求,用原始 Document 和桥脚本两条请求防止空迹通过;比较文本、DOM、CSP、冻结 transport、回调排空及包和发行镜像字节。浏览器请求观察只能证明其可见的网络请求,不能把未观察到请求或空回调 Map 写成精确零 postMessage IPC。辅助测试仅验证失败边界。R20 正式入口在资源校验前缺 native Bun PATH,依赖恢复经历 TLS 失败;完成依赖/helper/frontend 后,短命进程检查竞态又中断构建,最后耗尽 90 分钟总预算,未产出验收 EXE。九个真实运行时步骤均未执行。R21 已补构建前 native bun.exe 定位与固定版本检查,安装局部预算由验收入口经 build 传到 installer(每次 1~1200 秒、默认 300 秒);短命进程仅在原进程退出或句柄已触发等正向证明下忽略,仍在运行却无法检查时失败。Tauri 结束后通过同一个独占文件句柄比较 Cargo.toml:原字节不写,只有精确 CRLF→LF 结果才恢复;语义编辑、占用或删除均保留现状并拒绝 identity,构建原错和检查错同时保留。13 项准备回归及进程 owner 回归通过;这只修复工具,未运行本轮 Cargo/Release/产品,仍须下一轮一次正式构建和九阶段验收。R22 唯一正式入口因 PATH 前置 MSYS 而选中 GNU tar,在 JDTLS 解压阶段中断,未进入 Cargo、未产出 EXE,九个真实步骤全部未执行。准备脚本现从平台 SystemDirectory 定位原生 tar,不依赖 PATH;缺失在缓存/输出变更前失败,解压非零保留原生错误并停止后续复制。16 项离线准备回归通过,涵盖绝对盘符/空格路径的小 tar.gz/VSIX、假 PATH tar、坏归档及提前缺失;这仍只证明工具边界,必须另做一次正式产品验收。真实安装、文本页、隐藏重开和退出仍 pending,不能据此升级 M10。 + +截至 R19 采样,官方 v0.1.0 prerelease 仅列出完整 Darwin 源码插件包及校验和,未列出 Windows 包。ZIP 的完整 14 文件、tag 字节和 7 个 SDK 锁哈希已核对;这不证明与当前宿主动态兼容。完整 manifest 要求 serverProcess:1、vcsProviders:1,固定 Windows 宿主尚未声明后者。该包为 unsigned Community 来源,需用户明确批准;Marketplace 不接受 unsigned,GitHub 来源与校验和不能代替发布者签名。R20 在 ignored 的固定上游 main 717d23cd417f796d8487b3b7cbc8150a8356b512 上应用本地打包补丁,Windows x64 使用系统 bsdtar 3.8.9 顺序组装 darwin、windows-x86_64 目标:两 ZIP 各 14 个普通文件、CRC、7 个 SDK 锁哈希与 24 条静态导入闭包核对通过,payload 相同。manifest 仍为 0.1.0,完整 requires 未删减;这是本地非官方 unsigned 候选,Darwin 只是分发标签,未在 Darwin 执行。R21 的 ignored 上游补丁改为未发布 0.1.1 候选,已迁移 CI/release 的显式 platform/tag/output、四资产集合及 existing-release 严格校验,并在写入前拒绝输出与源码的真实路径/junction 交叠。两 ZIP 各 14 文件、7 SDK 锁哈希、24 导入边、CRC/SHA 与相同 payload 重验通过,12 项离线打包/发布分支检查通过。R22 以私有 staging 的 exclusive 新文件和 rename 替换 checksum 目录项,修复硬链接截断输入;README、锁定 SDK 和同 hash 硬链接回归及发布失败边界通过,五个真实 release shell 离线 gh 分支已纳入可携带的上游测试。15 项本地检查及双 ZIP 全量审计通过,11 文件完整补丁往返逐字节一致。审查另发现 P1:新增测试进程封装在 Popen 后才归属 Windows Job,快速子孙可能在分配前逃逸;当前 Job active=0 不能证明逃逸成员退出。需先归属再释放目标及确定性短命 root/失败回收回归,修复前上游补丁仍 pending,不作为可发布方案、不纳入主仓产品源码。未运行 Provider、产品、SVN、签名或发布;Windows 正式安装、依赖探测、initialize 和界面仍 pending,保持 partial/pending 与现有门控。 + +R23 正式入口已通过原生 tar 的 JDTLS 准备,随后依赖安装先耗尽 1200 秒局部预算,既有的一次 clean retry 又在 tree-sitter-cli 的 GitHub 二进制下载遇到 TLS ECONNRESET。尚未进入 Cargo,没有 EXE 或构建 identity,ui:1 九阶段仍为 0/9,不能用准备回归或旧 Release 补足。该下载由 npm 包的 install.js 另行发起,npm tgz 的 SRI 不证明 GitHub 二进制;修复应先核实当前 Node 与子进程实际采用的代理/重定向路径,保持 TLS 校验,再在同一任务内完成完整安装、冻结源码、构建和一次正式验收。 + +R23 的 ignored 上游打包补丁已修复上述先启动目标、后归属 Job 的竞态:先配置关闭即终止成员的非 breakaway Job,启动仅等待未触发事件的可信 Python bootstrap,归属成功后才释放目标。目标及子孙由内核继承 Job;清理使用保留的进程句柄和成员计数,不用历史 PID 猜测所有权。配置失败不得启动进程,归属失败不得启动目标;原始错误和清理错误同时保留。6 项 owner、9 项打包以及 zsh/bash 各 5 项真实 release shell 离线分支共 25 项通过,双 ZIP 全量审计及 12 文件完整补丁往返逐字节一致。复审未发现阻塞缺陷;这仍是未发布 unsigned 0.1.1 本地候选,POSIX 进程组清理没有在 Linux/macOS 内核实测,不作为跨平台运行、Provider/SVN 或正式发布证据,既有能力门控保持关闭。 + +R24 的真实 Node 24.15 子进程已验证两个 tree-sitter-cli 版本的 CONNECT、受系统信任的 TLS 以及 302/200 响应头,但探针随即停止读取;响应头不能证明完整下载、gzip 或 lifecycle 安装成功。本轮安装还观察到 tree-sitter-c-sharp 与 @xterm/xterm 的 npm tarball SRI 完整性错误,随后主动取消;不能把两条错误分别记为首次及内部重试。降低并发后的另一次安装达到 1200 秒期限,日志不足以定位停在何处。因此未进入正式构建,ui:1 九阶段继续 0/9 pending。下一步先对比 lock 固定镜像与 npm 官方源的完整字节/SRI,再验证两个真实 install.js 的完整下载;不得只凭 HTTP 200、重设相同代理或扩大期限宣称已修复。v0.23.2 官方资产摘要仍为 null,未建立二进制复用路径,45 项离线/helper 通过只证明辅助边界。 + +针对失败包的四次完整传输补证表明,官方源的 tree-sitter-c-sharp@0.23.5(3,685,327 字节)和 @xterm/xterm@6.0.0(1,418,562 字节)均匹配当前 lock SRI,包内精确版本及依赖声明也一致,无需因此修改版本或这两个节点的依赖。镜像 C# 包在本地 60 秒截止前未完成,不能把截止触发的 reset 当作远端主动重置;xterm 两源完整字节相同。xterm 官方 metadata 的 TLS 请求失败,只有 C# 另获官方 metadata integrity/shasum;这些证据支持验证官方来源方案,尚未证明 Bun 能覆盖 lock 显式 URL、全量安装或 GitHub lifecycle 下载成功。 + +上游打包候选的 R24 完整 12 文件补丁已经完成 Windows 本地源码交付,25 项去重回归、干净 clone 应用往返、四资产和双 ZIP 全字节验证通过,复审未发现阻塞缺陷。Linux 正常退出、超时与短命 root 后代清理三项回归已接入同一正式测试入口,CI/release 先运行并传播失败,报告以 always 条件上传;本机 Linux 环境缺工具,三项没有动态执行。MSYS zsh/bash 属 Windows 内核证据,不能填补 Linux/macOS 验收;维护者仍需应用候选并运行 Ubuntu CI、完成签名/安装及 Windows Provider 与宿主动态兼容验证。此结论不开放 vcsProviders/ui:3,也不代表已发布 0.1.1。 + +正式验收入口的所有模式在构建前后记录同一完整 Windows/第三方输入库存;`-SkipBuild` 在复用前同时核对库存、Git 提交、x64 目标、配置和 EXE 哈希。这个规则同样适用于默认、LSP 和设置链,避免 HEAD 未变但工作区源码已改变时错误沿用旧二进制。库存只写本轮隔离验收目录,不是跨工作树缓存;缺库存的旧 marker 必须重新构建,不能事后补写冒充原构建。 + +所有验收模式共用包含已跟踪与未忽略新文件的源码清单,覆盖 Windows、共享前端、Rust、脚本、根依赖和 `third_party` 输入;忽略的依赖与构建输出不进入清单。否则新增的 TS、Rust 或第三方描述文件可能改变构建,却仍被误判为原源码。Git 枚举和逐块哈希共用截止时间,SUBST 别名先解析真实根;不能缩减旧 marker 的清单来迁就某个验收模式。修复验收 helper 后保留原始构建及产品证据的提交身份,不能把旧二进制写成修复后提交已重建。 + +默认与 LSP 验收在安装、重启、进程退出和版本交易边界,对完整安装目录执行有界快照,并与该阶段原始 ZIP 的文件字节及目录清单比较。不能只验证安装记录里的归档摘要:它不能证明运行时没有往版本目录新增状态或改写服务器。失败先保存实际清单和差异再抛错;替换后按新包比较,旧阶段证据保留,不能重采基线掩盖写入。 + ## 验证 +- 当前 Windows x64 集成基线 `04f411889d4264343fb2e548619543f5136a25d5` 已完成正式 Release、ui:1 字面文本九阶段、默认与 LSP 生命周期 19 项,以及受控设置九阶段。LSP 链覆盖真实编辑器服务器及子进程、初始化中断、关闭重开、双产品窗口、禁用重启、替换升级回滚卸载;八个检查点的安装目录均与对应 ZIP 字节一致。设置驱动按设计非零退出,因为真实 functions 与完整 M10 前提仍未满足;受控 LSP 也不证明真实 JDT/Provider 包已验收。 +- 同一冻结基线的 ui:2 原入口因源码清单少算五个 `third_party` 文件而在启动前失败。归档的独立适配入口仅改用既有的完整清单计算器,重新计算 5011 项并与原 marker 精确比较后,原生回退链九阶段通过;产品、IPC、回包和 marker 均未替换。发行镜像、安装包与归档字节未变,owned 进程及隔离 profile 已清理。后续清单修复的 web helper 13 项及 text 兼容 12 项通过,不扩大上述运行时提交范围。ui:3、functions、vcsProviders、buildProviders、buildSystems、签名完整包和 arm64 继续 pending,整体保持 partial/pending。 +- R25 已在冻结提交 `2193e60a4cc3df484c4ee129dc437b992b330548` 完成官方来源的 frozen 全量安装、两个原始 CLI lifecycle 及当前源码 Windows x64 Release。正式入口共调用四次;前两次在构建准备中失败,第三次产品九阶段通过但报告聚合失败,修复原始 progress JSON 的归档位置后,第四次 `-ResumeBuild -TextWindow` 退出 0。最终受控 unsigned API3/ui:1 九阶段 9/9,通过完整源码库存、EXE/配置身份、精确 DOM/CSP/请求、计数器正控、回调排空及资源哈希检查;全部 owned 进程和 profile 已清理。不能把网络观察写成精确零 postMessage IPC,也不能扩大为 ui:3、VCS Provider、签名包、arm64 或完整 M10。该 lane 未能执行原 zsh 只读门禁,只通过等价源码扫描和真实资源不变检查;集成窗口另行执行原脚本并保留结果,不改写 lane 的原始记录。 +- 设置作用域回归:`declarative-plugin-settings-editor.test.tsx` 使用真实生产控件验证继承、全局项锁定、空值、保存/取消、重置与冲突;`declarative-plugin-reload-project.test.ts` 使用真实模型服务验证保存凭据、审批、不可取消预检与可取消模型阶段、清理失败重试和同路径重开拒绝迟到结果。`build_tests.rs`、`build/tests.rs` 和 `settings/snapshot/tests.rs` 覆盖目标窗口隔离、持久设置修订、真实窗口资源实例、精确原操作清理重试与 Core 归属。集成窗口已通过原生逐项计时测试;前端使用原生端口替身,真实双窗口 Provider 与发行目录哈希验收仍须另行完成。 +- 原生面板函数回归:`functions/native/tests.rs`、`functions/runtime/tests.rs` 和网页函数测试覆盖倒序打开、旧凭据清理、作用域隔离、晚审批和失败清理重试;`native-functions.test.ts` 与三个共享原生 fixture 的组件回归验证生产控件到 typed port 的完整调用。受影响 Rust 与 Frontend 计时测试、完整 TypeScript、格式和静态门禁已通过;这些证据不代替真实包、停靠布局和 WebView2 人工验收。 +- VCS 写入回归:`vcs_actions_tests.rs` 使用真实 Core decoder/ownerOf 覆盖普通文件、消失和双端移动端点拒绝、隐式父目录扩展、Windows 路径别名、跨根与歧义;`vcs_session_tests.rs` 的受控 runtime 检查 typed Core request、真实 reply decoder、最终复验拒绝、未知结果与精确作用域清理。R12 原生与前端计时回归已通过。R13 新增 resolve 的冲突能力/精确端点、cleanup 的完整整根范围,以及确定失败/未知结果经过清理重试后不变且不重发的回归;集成树 `vcs-resolve-cleanup.test.ts` 与 `vcs-actions-panel.test.tsx` 聚焦计时 13 项通过。新增原生回归已随最终宿主计时测试通过;`vcs_session_core_tests.rs` 的真实 Core stdio 夹具由 `node scripts/test-windows-plugin-vcs-core.mjs` 显式执行并通过。R14 将同一夹具扩展为 resolve/cleanup/add/delete 四个操作,单操作请求与清理各有 5 秒和 13 秒上限,入口为唯一用例设置 90 秒预算、绝对 `LITHE_VCS_TEST_NODE` 和 `--ignored`,四操作已显式运行并通过(1304ms),结束要求恰好一个通过结果;普通宿主测试中的 ignored 不能作为验证。正式 Provider、系统 SVN 客户端、生产包与进程树验收另行完成。 + - 多窗口事件投递必须显式指定当前产品窗口的 `Window` target。Tauri 默认 `Any` 监听也接收发给其他 label 的事件,原生 `emit_to(ownerLabel)` 本身不足以隔离接收者;文件请求与生命周期确认均绑定当前窗口,防止其他工作区误处理请求或重复确认。`plugin-lifecycle-listener.test.ts` 检查两类监听的目标。 - 2026-10-24 Windows Release 实机验收:Playwright/CDP 连接真实 WebView2,测试包安装、ui 1 文本渲染、工作区关闭重开、状态隔离、两个产品窗口归属、主题/语言与重载、过期工作区 openFile 拒绝、双窗口卸载关闭全部插件页均通过;无重复确认异常,外站 HTTP GET 为零,安装包及发行资源哈希不变,应用正常退出。该测试包不替代真实语言服务器的完整安装→启动→即时设置验收,也不覆盖停靠布局。 - 管理并发回归:`lifecycle.rs` 覆盖原生使用计数排空、禁止晚到获取、逐窗口确认、停止失败、窗口断开和有界超时;`lsp-core-adapter.test.ts` 用可控 Core 响应覆盖停止期间晚到的服务器,断言先停止再拒绝,且 `pluginId` 没有丢失。`plugin-lifecycle-listener.test.ts` 验证收到停用事件立即撤销入口、等待运行和 LSP 两方后才确认。 @@ -111,6 +228,7 @@ M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁 - 网页隔离与上下文回归:`web.rs` 覆盖同包和外站 URL 的导航/桥接共用检查、真实路径与内部/外部目录链接;Windows 用无需提权的 junction,Unix 用目录符号链接。`plugin-web-appearance.test.ts` 执行原生宿主桥脚本,验证语言、主题、根变量、主题事件与外站不暴露桥;前端文件测试验证原生真实目标传递与拒绝传播。 - 版本探测回归:`runtime_probe_collects_output_and_rejects_overflow`、`runtime_probe_reclaims_inherited_pipes_after_parent_exit` 使用当前测试二进制作为固定夹具;后者让父进程退出、后代保留管道,断言调用有界结束且后代已退出。测试环境变量仅附加到子进程,不改测试运行器的全局环境。运行 WindowsRust 与 Frontend 计时 harness,检查 `.artifacts/test-stability/index.html`;真实 WebView2 验证还要由本机 HTTP 计数器确认外站 GET 为零,同时检查导航、弹窗/下载、动态主题、包文件和发行目录哈希不变;单凭页面 URL 没变不能证明请求未外发。 +- 路线图 M1 信任链:`plugin_host/signature.rs` 用 RFC 8032 测试密钥锚定 Ed25519 原语;共享公开向量 `shared/contracts/plugins/v1/fixtures/signature-vectors.json`(含非 ASCII 文件名、canonical payload 字节、确定性签名与 contentDigest)由 `shared_fixture_verifies_end_to_end` 断言,Swift 端 `PluginSignatureVectorTests` 消费同一文件(macOS 实机运行待 CI)。`install_tests` 走真实 prepare→review→confirm→publish→scan 路径(测试密钥经 cfg(test) 注入,生产不可达):记录提交失败恢复旧字节与旧记录并可重试、首次安装记录失败不留字节、staged 字节绑定批准摘要、同版本替换拒绝改动字节、已签名磁盘包安装与加载、未知 key/坏签名/sameTeam 磁盘包拒绝、prepare 后替换/增删文件拒绝、回滚候选按其自身 provenance 验证且 bundled 声明拒绝;`tests.rs` 覆盖重启扫描隔离篡改/增删文件、installed 记录伪造 bundled 来源隔离、官方 ID 拒绝无签名社区安装(seal 错误)、社区包中存在的签名文档必须验证;`signature.rs` 单测覆盖真实资源根包含检查。`declarative_plugins_confirm_disk_install` 与回滚保持 #1424 的逐窗口停止屏障,失败时旧版本与旧记录保持可用。确认编排里,会话身份只解析一次且位于统一清理块内,其值既作为真实 `lifecycle::begin` 的屏障键、也显式传入共享编排作为发布前身份校验的期望值——helper 不重读身份,发布前拒绝身份漂移(屏障期间会话换成其他插件时,任何字节都不在错误屏障下发布);安装记录的读取与回滚链构造同样在屏障内完成。屏障拒绝与校验拒绝(含身份解析失败)都释放快照与会话记录。事务中断遗留的 `*.previous-tmp` 恢复备份(完整版本名命名,相邻补丁版本不碰撞)在下一次发布前按 durable record 当前批准的摘要判定:备份即批准字节则恢复、批准字节已在他处完整存在则清理备份、无法证明则拒绝歧义状态并完整保留数据,跨版本的孤儿备份在其批准字节可证时先归位自身版本目录,恢复失败保留备份并如实报错,绝不盲删。恢复前还校验记录的插件身份与单段版本路径,损坏记录不能把备份移到其他插件或用户目录。清理失败的回归从真实发布事务留下备份后再触发下一次记录提交失败,避免只手工创建预期路径而漏掉实际备份命名错误;测试文件锁与临时目录均用作用域清理。 - M4 Web 工具窗口:`plugin_host/web.rs` 测试覆盖包路径规范化(`..`、编码分隔符、空段拒绝)、CSP 内容断言(default-src none、无 wasm-unsafe-eval、frame-ancestors none)、桥脚本冻结与作用域、状态键隔离;web-panel fixture 随 `ui:[1,2]` 声明提升转为被宿主接受(能力门控测试同步)。桥 v0 的 build.*/functions.* 返回 `plugin.web.unknownMethod`,等构建系统能力接入。 @@ -142,6 +260,8 @@ M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁 - `windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx`(设置编辑) - `windows/tauri/src/features/plugins/declarative-plugin-run.ts`(契约插件运行入口) - `windows/tauri/src-tauri/src/plugin_host/web.rs`(Web 工具窗口 scheme、桥与状态) +- `windows/tauri/src-tauri/src/plugin_host/signature.rs`(发布者验签、contentDigest、官方 ID 固定信任策略;依赖 `ed25519-dalek`) +- `shared/contracts/plugins/v1/fixtures/signature-vectors.json`(双端共享签名测试向量;macOS 消费方为 `macos/Tests/LitheTests/PluginSignatureVectorTests.swift`) - `Plugins/win/Official/PhpSupportContract/`(契约版 PHP bundled 包) - `windows/tauri/src/features/plugins/declarative-plugins-section.tsx`(设置内管理页) - `windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts`(契约插件回落来源) diff --git a/.agents/notes/implemented/feature/2026-10-21-declarative-native-plugin-ui.md b/.agents/notes/implemented/feature/2026-10-21-declarative-native-plugin-ui.md index 599f2f787b..e967daf08b 100644 --- a/.agents/notes/implemented/feature/2026-10-21-declarative-native-plugin-ui.md +++ b/.agents/notes/implemented/feature/2026-10-21-declarative-native-plugin-ui.md @@ -4,7 +4,7 @@ ## 先说结论 -插件可以声明树、列表、输入框、下拉选择、复选框、工具栏按钮和右键菜单,由 macOS 使用 Lithe 共享控件显示。开发者只提供数据与函数,不必开发前端样式。本轮复用现有插件函数调用和跨项目生命周期,不增加后端执行系统;Windows 原生渲染留待后续独立实现和验证。 +插件可以声明树、列表、输入框、下拉选择、复选框、工具栏按钮和右键菜单,由宿主使用 Lithe 公共控件显示;开发者只提供数据与函数,不必开发前端样式。macOS 已接入原生工作台,Windows 已接入保留准入门控的渲染器、类型化函数通道及左、下、右三侧面板,继续复用既有执行和清理机制。Windows 的 `ui:3` 准入仍关闭,位置覆盖仅保存在当前宿主窗口和工作区的内存中;完整产品、WebView2(Windows 网页视图运行时)及与 macOS 六组停靠的对齐仍未验收。 ## 问题 @@ -14,7 +14,7 @@ 在 ui 能力版本 3 下引入独立版本的原生声明,首版支持一棵树、一个列表或一个表单及其面板动作。共享 Core 同时负责初始声明与刷新数据的结构、所有权和预算校验(原生适配器不复制节点规则),应用功能模型负责选择、展开、数据替换和事件,视图只使用已有标题栏、工具栏按钮、菜单、行样式和滚动条。声明不能携带颜色、字体或脚本。树/列表的行容器在共享行背景外提供 Tree.horizontalInset 的左右留白,让悬停与选中保持同一外边界;文字内部仅保留层级缩进。键盘区域复用 litheFocusEffectHidden 关闭系统矩形焦点环,仍通过共享行的活动/非活动选中配色表达焦点,保留方向键和无障碍。 -数据源和动作复用现有插件函数入口:它负责授权、工作区和插件范围、超时及取消。原生窗口使用与网页相同的会话登记表,插件停用、替换及关闭项目时同步取消请求。隐藏窗口也释放请求,重开从已有状态存储恢复选择和展开。发布包与插件包只读,状态只写平台提供的原有可写存储,不改变签名或 Sparkle 增量更新基线;没有新增可跨工作树复用的缓存资源。 +数据源和动作复用现有插件函数入口:它负责授权、工作区和插件范围、超时及取消。原生窗口使用与网页相同的会话登记表,插件停用、替换及关闭项目时同步取消请求。隐藏窗口也释放请求,重开从已有状态存储恢复选择和展开。发布包与插件包只读,macOS 状态使用平台提供的原有可写存储,Windows 面板草稿与位置覆盖只保存在内存中,不改变签名或 Sparkle 增量更新基线;没有新增可跨工作树复用的缓存资源。 正确做法:插件声明树和刷新动作,函数只返回稳定节点 ID 与标签。不要这样做:按插件名在工作台增加视图分支,或为了渲染树另建后端进程与生命周期。 @@ -150,8 +150,45 @@ Git未跟踪目录摘要和可见行缓存;这些界面仍使用完整公共 本次同步后的验证:macOS 323项/56套件通过(10.561s),覆盖公共按钮、实际声明式挂载、输入/树列表、设置、Git及HTML预览;Rust Core全量1106项通过。UI Lab源码及打包后的SDK检查通过。完整产品人工视觉验收仍pending。 +### Windows 渲染、函数通道与三侧面板 + +Windows 的树、列表和表单复用已有公共控件与虚拟列表,由共享 Core 校验声明、字段草稿和返回节点。面板通过类型化函数接口 `NativeFunctionPort` 加载数据和执行动作,复用设置及网页已有的 Core 函数会话、审批和清理提示;前端不能自行挑选函数、程序或构建实例。原生登记由可信插件目录绑定包内容身份、面板、宿主窗口实例、工作区和唯一构建实例。渲染与函数接线已存在,但不因此开放 `ui:3`;正常插件准入仍保留门控。 + +左、下、右三侧显示接入既有 `MainLayout`、`BottomPane`、`TerminalDockLayout` 和 `ResizablePane`。声明位置是首次打开的默认值,入口右键菜单允许移动到另两侧;移动只关闭同一贡献原来的槽,其他工具的选择和可见状态不受影响。同一贡献只挂载一个面板,每个挂载实例持有准确的原生句柄;隐藏、移动、同槽替换和工作区切换均释放该句柄,晚到的旧登记不能释放后来的所有者。关闭按钮只有在包、工作区、选中贡献和位置仍匹配时才关闭,并把焦点还给对应入口;过期回调不能关闭新工作区的面板。 + +位置覆盖按当前宿主窗口和工作区保存在内存中,面板的选择、展开和字段草稿还按包内容身份隔离。隐藏重开、在三侧之间移动及返回同一工作区可以恢复现有草稿;包替换不能继承旧包草稿。位置覆盖可以跨同一窗口内的暂时移除保留,但不是重启恢复契约,也不写入插件包、发行目录或跨工作树缓存。 + +正确做法:把当前贡献从右侧移动到底部,复用现有分隔布局并释放原槽持有的句柄。不要这样做:为三个位置分别创建函数执行器,或让旧面板的关闭回调直接修改当前活动项目;前者会重复执行,后者会关闭无关工作区的工具。三侧接入也不代表已经具备 macOS 六组停靠、自由拖动、入口重排或持久布局。 + +R13 的 `native-docking.test.tsx` 在集成树独立进程中实跑 10 项、137 项断言通过(5.54s,最慢 218ms)。测试组合真实 `TerminalDockLayout`、`ResizablePane`、`BottomPane`、`NativeToolWindow` 和 `NativePanel`,覆盖三侧挂载与移动、精确释放、隐藏重开、关闭后焦点、工作区及包隔离、长树、窄窗口和组合输入;插件目录、UI 状态动作及原生宿主使用替身。该证据不能代替真实 `MainLayout` 产品、完整包、双窗口与 WebView2 的视觉、输入法及浅深色验收,Windows 状态继续保持 `partial` / `pending`。 + +R14 的 `native-main-layout.test.tsx` 补齐真实 `MainLayout` 组合链:插件目录刷新与撤回、生产活动入口、UI 状态动作、三个布局槽、原生面板和类型化函数通道均保持真实;原生调用、审批、校验、终端内容及无关功能仍使用替身。集成树独立计时实跑 9 项、161 项断言通过(Bun 2.29s,最慢 329ms),覆盖三侧同时显示、终端挂载身份不变、逆序登记与迟到释放、同槽替换、工作区及包隔离、关闭后焦点,以及重建后的字段和树状态。 + +重建测试保留当前进程的工作区存储,并使用内存会话仓库验证既有 UI 动作的保存与恢复;位置覆盖与草稿没有经历应用重启。切换宿主窗口标签后重建只证明模拟身份下草稿隔离与精确释放,没有同时启动两个真实窗口。这些组合证据仍不替代完整包、WebView2、真实输入法、浅深色和双窗口验收,也不开放 `ui:3` 或升级 `partial` / `pending`。 + +Windows 工具栏按 Core 批准的 15 个 host 图标 ID 显式映射到公共图标;未声明时保留 refresh/invoke 默认。补齐的 IDEA SVG 与仓库已批准的 macOS 原文件逐字节一致,保留浅深变体,不从插件包解析任意路径。这些 Git 跟踪的静态输入在构建时打包、运行时只读,没有新增下载、缓存或可复用生成目录,不改变签名及增量更新基线。 + +字段和动作说明统一调用公共 `ui/info-tip.tsx`,由既有 Button 与 Base UI Popover 管理 500ms 悬停、点击或按钮键盘激活后的固定、再次激活关闭、Escape/外点击及卸载清理。禁用字段和动作只禁用实际输入或执行,说明按钮保持可用;关闭时仍保留可访问的辅助说明。不要在每个 renderer 内复制悬停计时或弹层,也不要修改全站 Tooltip 语义来兼容一个调用方。 + +Windows 搜索统一使用公共 `SearchField`。非空时的本地化清空按钮只把值交回调用者并恢复输入焦点;原生表单仅保存空草稿,按 Enter 后才提交包括空字符串在内的当前值。禁用或只读字段不能清空,说明按钮仍独立可用。尾部按钮的定位由公共 `Input` 的 accessory 接口负责,不在原生 renderer 复制输入框布局。组件键盘测试显式补发 Happy DOM 缺少的原生按钮默认 click,不代替真实 WebView2、输入法或主题验收。 + +表单的 primary/normal 只选择公共 Button 的语义变体:primary 使用 accent,普通动作使用 default;渲染器不自定义颜色或重做按钮。真实 native-controls 夹具回归同时检查禁用、说明、草稿恢复与完整动作参数。已开放插件设置列表中的公共 SearchField 另有真实 WebView2/CDP Enter、Space 默认清空、同一输入焦点、过滤和选中恢复证据;这不等于原生 ui:3、物理键盘、输入法或主题视觉验收。 + +Windows 虚拟树的键盘焦点属于 viewport(只挂载可见行的滚动容器),因此 Shift+F10/ContextMenu 由容器为当前选择打开公共 Base UI 菜单,并先滚动挂载目标行。菜单复用公共定位器的 anchor,不合成右键事件;菜单自己的按键不再触发树导航,输入法组合和重复按键不重复执行动作。行离开虚拟窗口后关闭菜单。Escape 可以返回树焦点;外点击或动作已把焦点交给新输入/对话框时,必须尊重新焦点所有者,不能无条件把焦点抢回树。该路径只有组件和 typed 函数端口回归,不能据此开放 ui:3 或宣称真实键盘、WebView2/IME 和视觉通过。 + +Windows 原生表单动作组按完整组宽度选择横排或纵排:测量现有公共 Button/InfoTip 行的自然宽度和既有间距,空间不足时整组纵排,不能用 flex-wrap 留下多列折行。只切换同一个容器的方向,保留 DOM、输入草稿、焦点和单次完整 values 提交;首次同步测量,后续 ResizeObserver 通知合并到一个动画帧,卸载取消待执行帧,避免观察回调改高度产生反馈错误;字段栅格与旧 width/span 兼容语义不变。组件与真实 Edge 几何回归证明宽窄切换,不代表真实 MainLayout/WebView2、物理键盘、IME 或完整包验收。仓库入口 `scripts/test-windows-native-plugin-ui-browser.mjs` 使用真实 Edge、生产样式和公共组件,Tauri 调用使用官方 mock;导航前捕获全部 window.error/pageerror。执行与清理共用总期限,工作进程先归属 Windows Job(关闭即终止全部成员的系统进程组)再释放,退出后核实零成员才删除 profile/cache;早退、超时或缺失清理证明均失败。JSON/HTML/JUnit 与独立 CI 步骤维护六阶段组件回归;它不启动产品,不等于 WebView2 或 ui:3 完整包验收,ui:3 继续关闭。 + +R21 为 NativeTree 增加真实 Edge 七阶段回归,复用上述 runner、生产样式、公共 ContextMenu/Dialog 和受控 typed 函数端口;键盘操作走 Playwright 浏览器默认行为。公共 modal 会把背景移出可访问树,测试使用局部 DOM locator 观察挂载行,不改变 modal。菜单打开后的上游 500ms 外点击保护期由 Playwright 受控时钟推进;随后第一次外点击由遮罩消费并关闭菜单,下一次正常点击才编辑输入,不声称立即穿透或同一次点击取得输入焦点。回归覆盖两种菜单键、Escape 焦点恢复、单次 Inspect、busy 禁用、对话框焦点和虚拟行卸载;真实包、WebView2、物理键盘、IME 与 ui:3 仍待验收。 + +R22 的表单栅格回归固定浏览器 viewport,只改变原 native-controls 夹具容器,在 900→639→640→900px 检查 12 列/span 的几何、同一控件 DOM、当前草稿与编辑焦点、禁用说明、公共 readOnly 原语和完整 values 单次提交。宽度按真实 grid 校准,包含滚动条占宽,不关闭 overflow 或硬写滚动条像素;segmented 的禁用沿 fieldset 检查实际按钮;typed 端口无 selection 为 undefined,不冒充下游 JSON null。主集成首次 Edge 发现测试断言错误,修复测试后七阶段真实 Edge 通过(36.5 秒),window.error/pageerror 为零,Job 归零后 profile/cache 已清理;未改生产组件。lane 原先的 TLS 安装失败和未执行项保持原记录,主集成通过只补受控组件证据,不升级 ui:3、WebView2、物理键盘、IME 或完整包验收。 + +控件输入回归继续使用原 native-controls 完整夹具和公共控件。真实浏览器负责指针、键盘默认行为与焦点:文本/多行编辑只改草稿,搜索 Enter 和声明的 onCommit 才提交;select、checkbox、segmented、stepper 按各自契约提交完整当前 values,最后显式按钮只提交一次。定位 checkbox 的公开角色及 stepper 的实际编辑节点,不能把 BaseUI 隐藏校验 input 当成第二个用户输入;下拉键盘操作先在局部期限内证明列表和选项焦点已就绪,不用 sleep 猜测上游安排。禁用组仍可查看说明,但指针和 Tab 不得进入编辑节点。失败证据单独保留,成功也必须核实页面错误为零、Job 零成员和 profile/cache 清理;这些是受控 Edge 组件证据,不升级 ui:3、WebView2、真实 IME 或完整包验收。 + +工作台组合回归将真实 MainLayout、活动入口、项目页签、公共控件、三侧槽及 typed 函数会话放在同一 Edge 页面中验证;原生 I/O、审批、终端内容和包/窗口外部事件仍使用与 Bun 组件测试共享的受控边界。替换包时暂停旧 prepare,再释放后必须证明旧操作没有 confirm 或读取结果,新 owner 的动作确实完成这两步;单独看到 cancel 不足以证明旧动作未执行,因为成功动作收尾也会取消原会话。工作区切换使用真实项目入口;同页重建受控窗口身份仅验证隔离规则,不冒充真实双窗口、产品 WebView2 或 ui:3 准入。 + ## 验证 +- Windows 受控浏览器:按 `docs/ci-builds.md` 中的 Playwright driver 解析命令运行 `scripts/test-windows-native-plugin-ui-browser.mjs`。R21 的动作组六阶段(41.0 秒)及 NativeTree 七阶段(38.5 秒)通过,window.error/pageerror 均为零,Job 归零后 profile/cache 已清理;worker 早退/挂起的生命周期回归通过,原始预期失败报告单独保留。R20 的页面错误、缺失模块、执行超时故障注入保持原证据范围。supervisor 早退时缺失证明,报告失败并保留隔离 scratch,不把故障注入算作产品验收。 - `./scripts/verify-shared-contracts.sh` - `cargo test --manifest-path rust/lithe-core/Cargo.toml --lib native_ui_validates_version_ownership_actions_and_budgets` - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginNativeUI|PluginWeb|WorkbenchContributionHostTests'` @@ -159,6 +196,9 @@ Git未跟踪目录摘要和可见行缓存;这些界面仍使用完整公共 - `./scripts/verify-service-boundaries.sh` - `./scripts/verify-runtime-bundle-immutability.sh` - `./scripts/verify-platform-feature-matrix.sh` +- Windows 组件测试分别在独立进程运行,避免模块替身互相污染:`bun test --timeout 15000 src/features/plugins/native-ui/native-panel-session.test.ts`、`bun test --timeout 15000 src/features/plugins/native-ui/native-panel.test.tsx`、`bun test --timeout 15000 src/features/plugins/native-ui/native-functions.test.ts`、`bun test --timeout 15000 src/features/plugins/native-ui/native-docking.test.tsx`(工作目录 `windows/tauri`)。 +- Windows 原生登记与函数边界:运行 Tauri 宿主的 `plugin_host::native_ui` 和 `plugin_host::functions::native` 定向测试;组件结果不代替宿主或完整产品验收。 +- Windows 真实工作台组合在独立进程运行 `bun test --timeout 15000 src/features/plugins/native-ui/native-main-layout.test.tsx`(工作目录 `windows/tauri`);按前端计时 runner 生成每项 JUnit/HTML 报告,不与其他含模块替身的 suite 合并运行。 ## 适用范围 @@ -168,6 +208,11 @@ Git未跟踪目录摘要和可见行缓存;这些界面仍使用完整公共 - `macos/Sources/Lithe/Application/Features/PluginNativeUIFeature.swift` - `macos/Sources/Lithe/Views/Workbench/PluginNativeToolWindowView.swift` - `macos/Tests/Fixtures/native-ui-lab/`(宿主验收 fixture,打包使用外部官方 SDK) +- `windows/tauri/src/features/plugins/native-ui/` +- `windows/tauri/src/features/layout/components/main-layout.tsx` +- `windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx` +- `windows/tauri/src-tauri/src/plugin_host/native_ui/` +- `windows/tauri/src-tauri/src/plugin_host/functions/native.rs` ## 2026-10-23 SDK 迁移后的验收归属 diff --git a/.agents/notes/implemented/testing/2026-10-25-plugin-fixture-coverage-gate.md b/.agents/notes/implemented/testing/2026-10-25-plugin-fixture-coverage-gate.md new file mode 100644 index 0000000000..174641691e --- /dev/null +++ b/.agents/notes/implemented/testing/2026-10-25-plugin-fixture-coverage-gate.md @@ -0,0 +1,82 @@ +# Agent 笔记:插件 fixture 覆盖门禁 + +状态:已实现 + +## 先说结论 + +`shared/contracts/plugins/v1/fixtures` 的覆盖检测器(`scripts/check-plugin-fixture-coverage.mjs` + `plugin-fixture-coverage.json` + 单测)已接入 `ci-macos.yml` 的 `changes` job——该 job 在 ubuntu runner 上对每个 pull request(含 Draft)运行,不新增 workflow、不占 macOS/Windows runner。任何人新增 fixture 而不在清单登记,真实 PR 检查会以 `unknown-fixture` 失败。门禁的防伪造立场是"证据与诚实分类",不是"永远保持 partial":允许全部 fixture 真实 wired 后零缺口通过,但没有证据的 wired、没有阶段归属的缺口一律拒绝。 + +## 问题 + +- 覆盖清单(M0 交付)靠显式登记维护。若只在本地运行检测器,"新增 fixture 忘了登记"只会安静漏过,`unknown-fixture` 不会让任何 PR 失败,清单会重新漂移。 +- 第一版真实仓库守护测试断言 `knownGaps > 0`:把 2026-10-25 当天的真实缺口数量固化成了永久约束。未来某天缺口真实补齐时这个断言必然失败,反而诱导后人删掉守护测试。 +- 缺口阶段(gapStage)的注释说 M0–M10,正则却排除 M0,语义与说明不一致。 + +## 决策 + +### CI 入口复用现有 Linux lane,不旁建 workflow + +`ci-macos.yml` 的 `changes` job 已有一个"scripts 测试 + JUnit/HTML 计时报告 + artifact 上传"的成熟 lane(Sparkle baseline 模式):`node --test` 双 reporter(spec 到 stdout、junit 到 `.artifacts/test-stability/.junit.xml`),再用 `parse-junit-cases.mjs` + `writeTestReportArtifacts` 生成逐项计时的 HTML/JUnit 报告,最后由同一 job 的 upload 步骤上传整个 `.artifacts/test-stability/`。插件 fixture 门禁镜像同一模式插入三步:检测器 CLI(真正的清单 vs 目录比较)、单测(`--test-timeout=15000` 本地期限)、计时报告生成。该 job 在 ubuntu 上对所有 PR 运行(含 Draft),因此 #1476 这类新增 fixture 的 PR 在合入前就会撞上 `unknown-fixture`。 + +门禁与测试的接线由测试自身守护:`check-plugin-fixture-coverage.test.mjs` 里有一条 CI 接线守卫测试,读取 `ci-macos.yml` 断言两个入口(CLI 步骤、测试文件)与期限/报告参数存在——workflow 改名或移除调用时,测试先红。 + +### 防伪造 = 诚实分类,不是永久 partial + +- 正例:一个全部 wired 且证据文件真实存在的清单,检测器必须通过且 `knownGaps = 0`(有专门单测锁住这条路)。未来覆盖补齐时门禁自然放行。 +- 负例:wired 却没有 Windows 证据、known gap 缺阶段或说明、证据引用文件不存在,全部拒绝(既有单测覆盖)。 +- 真实仓库守护测试只断言"清单与目录一致 + 每个非 wired 条目带合法阶段与说明",不再断言缺口数量。 + +### gapStage 语义:M0–M10 全部合法 + +路线图 M0 的退出条件把基线问题路由进聚焦修复 PR,所以"由 M0 拥有的缺口"是合法归属;M11 及以后不存在。正则为 `^M(10|[0-9])$`,注释与实现一致,并有单测同时覆盖 M0 接受与 M11 拒绝。 + +### M1 交接(#1476) + +`fixtures/signature-vectors.json` 在 #1476 中新增;其 PR 合入 preview 前(或同时)必须在清单登记,否则撞 `unknown-fixture`。最小登记条目(检测器已预置 `signature-vectors` 类别): + +```json +{ + "path": "signature-vectors.json", + "category": "signature-vectors", + "outcome": "success", + "coreEvidence": [], + "windowsEvidence": ["windows/tauri/src-tauri/src/plugin_host/signature.rs:663 shared_fixture_verifies_end_to_end"], + "windowsStatus": "<按 #1476 实际证据填 wired 或 validated-only>", + "gapStage": null, + "gapDetail": null +} +``` + +该类别是唯一允许 `coreEvidence` 为空的类别:签名向量由双端宿主各自的签名测试消费(Windows `signature.rs`、macOS `PluginSignatureVectorTests.swift` 的 RFC 8032 锚点、确定性签名、内容摘要、签名文档回读四组 @Test),没有 Core 校验面;豁免只对 `signature-vectors` 生效,其他类别仍必须有 Core 侧证据(有单测锁定)。`backend.plugin.json` 已由本 PR 随 preview 同步(`83017afa`/8471b998)登记为 `unwired` + `followup`——`followup` 是为"冻结路线图(M0–M10)之后落地、按计划 §6 单列后续阶段"的新能力保留的缺口阶段,是 M 编号之外的唯一合法值。登记条目的最终行号以 #1476 合并时的代码为准。合并顺序优先 #1474(门禁)先合、#1476 随后带登记合入;若 #1476 先合,它必须自带登记条目,否则其 PR 门禁失败。 + +## 考虑过的备选方案 + +- 新建独立 `ci-fixture-coverage.yml`:多一个 workflow、多一份维护与触发面,且与"优先复用现有门禁"的仓库惯例冲突,不采用。 +- 只在本地可选运行检测器:登记纪律依赖人的记忆,已知会漂移,不采用。 +- 保留 `knownGaps > 0` 作为防伪造断言:把"今天有缺口"固化成"永远有缺口",未来真实补齐反而红,防伪造应针对"没有证据的 wired",不采用。 +- 在 `verify-shared-contracts.sh`(Ruby 内联)里实现枚举比较:该脚本是契约语义校验(schema/拒绝码),把 Node 已实现的检测器复制一份 Ruby 只会分叉逻辑,不采用。 + +## 后果 + +- 收益:新增 fixture 未登记会直接打红 PR(含 Draft);单测有 15 秒本地期限与逐项计时报告;覆盖补齐路径(全 wired)有正例保证畅通。 +- 代价:`ci-macos.yml` 的 `changes` job 多两步(约几秒);维护清单成为改 fixture 的同 PR 义务;workflow 与测试之间由守卫测试双向绑定,改 CI 入口措辞时守卫测试需要同步。 + +## 验证 + +```bash +node scripts/check-plugin-fixture-coverage.mjs +node --test --test-timeout=15000 scripts/check-plugin-fixture-coverage.test.mjs +node --test --test-reporter=junit --test-reporter-destination=.artifacts/test-stability/plugin-fixture-coverage.junit.xml scripts/check-plugin-fixture-coverage.test.mjs +./.agents/skills/write-stable-tests/scripts/verify-test-stability.ps1 # 或 .sh +node scripts/verify-agent-notes.mjs +``` + +CI 等效路径:`ci-macos.yml` changes job(ubuntu)的 "Check plugin fixture coverage manifest" / "Run plugin fixture coverage tests" / "Generate plugin fixture coverage timing report" 三步,artifact 随既有 upload 步骤上传。 + +## 适用范围 + +- `scripts/check-plugin-fixture-coverage.mjs`、`scripts/plugin-fixture-coverage.json`、`scripts/check-plugin-fixture-coverage.test.mjs` +- `.github/workflows/ci-macos.yml` 的 changes job 中 "plugin fixture coverage" 三步 +- `docs/development/windows-plugin-fixture-coverage.md`(审计快照与维护规则) + +不覆盖:M1 拥有的签名/安装信任链 Note(#1476)、共享契约 fixtures 本体、平台功能矩阵。 diff --git a/.agents/notes/proposed/architecture/2026-10-25-windows-declarative-build-execution.md b/.agents/notes/proposed/architecture/2026-10-25-windows-declarative-build-execution.md new file mode 100644 index 0000000000..52fbafabde --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-10-25-windows-declarative-build-execution.md @@ -0,0 +1,206 @@ +# Agent 笔记:Windows 声明式构建执行模型与安全调度(M4 设计) + +状态:提议中 + +## 先说结论 + +Windows 要接入插件 API 3 的声明式构建系统(`buildSystems`,即插件用纯 JSON 描述"怎样认出工程、用哪个可执行文件、有哪些任务和测试"),方案是把全部构建协调放进 Rust 宿主的一个新构建会话登记表,原生界面和插件网页两个通道都调用这同一份事实;Core 的 14 个 `build.*` 命令语义只在 `lithe-core` 实现一次,Windows 经 `core_execute` 调用,不在宿主重写任何清单解释、argv 展开或输出解码。执行前统一走一条授权链:M1 信任闸门 → 生命周期隔离租约 → 窗口与工作区代次 → 操作 ID 与冲突检查 → Core 生成的计划校验。能力版本 `buildSystems:[1,2]` 与 `executableResolution:[1]` 只在对应行为完整验收后才声明,此前保持关闭。本设计依赖同一统一 PR 中 M1 的信任与安装记录接口;后续实现须等 M1 修复复核后推进,并在该 PR 内累计提交,不另建阶段 PR。 + +## 问题 + +正式路线图 [Windows 插件功能对齐 macOS](../../../../docs/development/windows-plugin-parity-plan.md) 的 M4 要求接入 `build.detect`、`invocationPlan`、`checkToolVersion`、`resolveOptions`、模型/任务/测试/依赖计划与解码、`syncFingerprint`、`languageImport`,以及 `buildSystems:2` 兼容输入与 `executableResolution:1` 的候选解析。当前基线 `1e78b59b`(#1424 合入后)的状态是: + +- 宿主 `host_capabilities` 只声明 `languages/languagesServers/serverProcess/settings/ui`(`windows/tauri/src-tauri/src/plugin_host/mod.rs:37`),Core 侧其实早已支持 `buildSystems [1,2]` 与 `executableResolution [1]`(`rust/lithe-core/src/plugins/capabilities.rs:55`),差的只是宿主接线。 +- 网页桥 v0 把 `build.instances`、`build.refresh`、`build.runTask`、`build.runTests`、`build.cancel`、`build.dependencies`、`build.setOptions` 一律拒绝为 `plugin.web.unknownMethod`(`windows/tauri/src-tauri/src/plugin_host/web.rs:787`),等待构建能力接入。 +- 现有 Maven 是宿主内置特例:`windows/tauri/src-tauri/src/maven.rs` 与 `windows/tauri/src/features/maven/` 按构建系统名字写死了配置、依赖输出与界面,社区插件无法复用;路线图要求它保留为回归基线,不在本轮删除。 +- 不处理会造成两个后果:一是 API 3 构建插件在 Windows 不可安装不可运行;二是若原生界面和插件网页各自实现一套构建状态,主窗口与插件 WebView 会对同一工作区得出两份互相矛盾的构建事实(路线图 §2.2 明确禁止)。 + +## 提案 + +### 总体形态:Rust 端唯一构建会话登记表 + +新增 Rust 模块 `windows/tauri/src-tauri/src/plugin_host/build.rs`(首批可拆 `build_registry.rs` 与 `build_resolver.rs` 两个文件),作为唯一构建操作入口。它持有按 `(宿主窗口 label, 工作区 ID, 插件 ID, buildSystemID, 实例根)` 索引的构建会话,每个会话记录:实例列表、模型、选项生效值、同步指纹、忙碌/过期标记,以及每个在途操作的唯一 operation ID、工作区代次快照与取消状态。 + +前端新增 `windows/tauri/src/features/build-systems/` 统一 TS service,只维护展示状态(树、任务分组、选项输入、忙碌与错误提示),通过 Tauri 命令调用 Rust。命令命名沿用现有 `declarative_plugins_*` 前缀模式(参照 `run_plans.rs:30` 的 `declarative_plugins_run_plan`):`declarative_plugins_build_instances / build_refresh / build_run_task / build_run_tests / build_cancel / build_dependencies / build_set_options`。注意 `windows/tauri/src/platform/tauri-core.ts:18` 的分发规则:点分共享 Core 操作走 `platform_invoke` 调度器且必须同步注册到 `main.rs`,所以这些命令一律按普通 Tauri 命令注册,不进入点分命名空间。 + +网页桥不新增第二套语义:`web.rs` 的 8 个 `build.*` 方法只做页面身份校验(复用 `web.rs:40` 的 `PageOwner`、`web.rs:80` 的 `with_page_owner` 代次检查、`web.rs:140` 的 `owned_label`),然后转发到同一个登记表函数。页面拿不到通用 IPC,也不能指定回复目标;结果只回原页面。 + +选择 Rust 而不是 TS 承担协调,是路线图 §2.2 预留的既定选项:现有接口不足以支持"网页 → 主窗口 TS 模型"的定向请求/回复通道(web 桥没有回复路由),而 Rust 已拥有信任、生命周期与进程能力;TS 若维护第二份构建事实必然漂移。 + +### Core 命令接线清单(14 条全部可复用,宿主侧全部缺失) + +Core 命令注册在 `rust/lithe-core/src/build/commands.rs:91-246`,经 `plugin_host/scan.rs:190` 的 `core_execute` 调用(与现有 launch/run_plans 相同入口): + +| 命令 | Windows 接线 | 缺失接线 | +| --- | --- | --- | +| `build.detect` | bootstrap 扫描后 + 选项/插件变化后重新识别;static 与 hostIntegration 模型随识别返回 | 全部缺失 | +| `build.invocationPlan` | 解析器输入:候选顺序、环境变量白名单、版本探测参数 | 全部缺失 | +| `build.checkToolVersion` | 合并 stdout/stderr 交 Core 校验,复用 `launch.rs:430` 的有界探测执行器 | 全部缺失 | +| `build.resolveOptions` | 选项合并、`dropped` 处理、配置指纹 | 全部缺失 | +| `build.modelPlan` + `build.decodeModel` | static:识别时直读;command:用户点加载时执行模型命令、stdout 交 Core | 全部缺失 | +| `build.taskPlan` / `build.testPlan` | 工具窗口与节点菜单运行 | 全部缺失 | +| `build.dependencyPlan` + `build.decodeDependencies` | 依赖节点首次展开时加载 | 全部缺失 | +| `build.decodeDiagnostics` / `build.decodeTestResults` | 任务/测试输出解码,结果进 Build Output | 全部缺失 | +| `build.syncFingerprint` | 监听文件摘要,判断模型过期 | 全部缺失 | +| `build.languageImport` | Java 会话启动前归一化 Maven/Gradle 输入,转发给 LSP 启动配置 | 全部缺失 | + +Core 错误语义保持不动:清单/声明问题返回 `{status:"rejected"}` 稳定错误码,请求本身格式错误才是 `invalid_request`;`plugin.build.invalidOutput/invalidSelection` 等错误码直接透传到界面。 + +### 三种模型的执行路径 + +- `static`:识别时 Core 直接读项目文件(如 Cargo.toml),零进程,打开项目即可展示。永远可用,不需要同意流程。 +- `command`:模型命令是进程。用户点击"加载模型"或运行任务时,先 `build.invocationPlan` 拿候选,Windows 解析器(新 `build_resolver.rs`,对照 `macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift`)按候选顺序解析出可执行文件与受限环境,再启动进程:模型/依赖加载是一次性有界收集(stdout 单独捕获交 Core 解码、stderr 进 Build Output、每流上限、取消期限),复用 `launch.rs:430-468` 已验证的 `lithe_git_host::run` 执行器语义(Job Object 进程树、非阻塞管道、有界收集)。项目内 Wrapper(`projectFile` 候选)一律视为执行仓库代码,做版本探测和运行都按同一信任策略,不采信清单自报。 +- `hostIntegration`:目前只有官方 Maven。经 Core 的 Maven 集成解析 reactor/依赖树/Surefire 结果,宿主只负责按计划启动 `mvn`。现有 Windows Maven 专用流程(`maven.rs`、`features/maven/`、`main.rs:281-290` 注册的命令)原样保留作回归基线,与声明式路径并存,去重规则与 macOS 相同:声明式插件接管后实例从空状态开始,不沿用专用流程缓存。 +- `provider` 来源:M4 不接。`buildProviders` 能力继续关闭(宿主不声明,Core 在 validate 阶段就拒绝声明 Provider 的包),Provider 会话属 M5。`build.detect` 对 Provider 来源只报告不加载的语义由 Core 保证,宿主无须处理。 + +### executableResolution:1 与 buildSystems:2(冻结范围内,不是未来需求) + +- 候选五种来源已在 Core 定型(`rust/lithe-core/src/plugins/executables.rs:82-103`):`projectFile`(工程内 Wrapper)、`environment`(声明过的环境变量)、`command`(PATH)、`runtime`(java/node/go 等运行时)、`setting`(用户显式选择的全局文件偏好);`uses_resolution()`(`executables.rs:115`)定义了需要 `executableResolution:1` 能力的声明形态(setting 来源或 priority/insertBefore/insertAfter 排序元数据)。fixture `build-tool-path.plugin.json` 同时要求 `buildSystems:1 + settings:2 + executableResolution:1`,是本项的验收输入。 +- 解析语义照抄 macOS 侧 `macos/Sources/LitheCoreContracts/Execution/ExecutableResolution.swift:5-29` 与构建契约 Note 的"统一的候选解析扩展"一节:数组顺序是默认优先级;`setting` 来源为空字符串时跳过该候选、非空但无效时是终止失败(报错并停止本次模型/依赖加载,不得静默回退 PATH 或让 Provider 自行发现);Runtime 候选把所选目录前置到候选自己的 PATH 再探测与执行,失败候选的 PATH 不传给下一个候选;Provider 的可选 tool 提示复用同一解析结果(M5 前不触发)。 +- `buildSystems:2` 的增量是清单里 `invocation.executableSetting` 旧适配器:Core 在 `rust/lithe-core/src/build/manifest.rs:1368-1373` 要求版本 2 且存在 effect 为 reload/nextRun 的可执行全局设置,否则拒绝。Windows 声明 `[1,2]` 即可同时接受两种清单;不要为了让旧包可装而声明成只有 1。 +- 用户显式选择的工具路径存进现有声明式设置存储(`windows/tauri/src-tauri/src/plugin_host/settings.rs:15` 的 `app_local_data_dir()/plugin-settings/.json`,项目覆盖在 `settings.rs:26`),不新建第二套工具偏好文件;Core 的 `resolveOptions` 与设置推送使用同一存储。 + +### tasks / tests / dependencies / languageImport / 运行入口 + +- 任务与测试:`build.taskPlan`/`testPlan` 生成精确 argv 后,走现有运行管线执行(`windows/tauri/src-tauri/src/run.rs:68` 的 `RunProcessManager`、`:675` 的 `run_resolve_launch`、`:200/265/308` 的按窗口进程清点与停止)。构建任务因此天然获得按窗口停止、进程树清理与关闭兜底,不必另写一套运行器;宿主只登记构建归属(插件、buildSystemID、实例根、operation ID),不把页面提交的 argv 交给通用 `run_start_process`。 +- 依赖:`build.dependencyPlan`/`decodeDependencies` 在依赖节点首次展开时加载;Maven 依赖树输出的临时文件语义对照 macOS(宿主在插件状态目录分配输出文件,读完即删),不写安装目录。 +- `languageImport`:`build.languageImport` 按返回的 `kind` 归一化为 `mavenContext`/`javaGradle`,在 Java 会话启动前转发给 LSP 启动配置(Windows 侧接线点在 Java 启动就绪链路,`windows/tauri/src/platform/java-launch-readiness.ts` 一线)。被动发现不执行 Wrapper、不做版本探测;找不到工具沿用 JDT 默认设置,不阻塞启动。 +- 运行入口:构建模型 `run` 声明与旧 detector、Worker 动作、`executionProviders` 的去重优先规则在 M4 只登记归属(声明式构建来源的入口标注插件与实例),完整运行配置对接按路线图单独排期;macOS 同一路径也缺,列为共享缺口,不另造契约未定义的行为。 + +### 多构建系统项目的选择 + +一个工作区同时识别出多个实例(不同构建系统或同系统多实例根)时:每个已启用插件的每个 `buildSystemID` 固定一个工具窗口(登记进现有工具窗口贡献注册表,ID 模式 `.build-systems:`,对照 macOS `BuildSystemToolWindowRegistration.swift`);窗口内用实例根选择器切换,候选与生效值必须来自所选实例,不允许取首实例代表全部。插件已启用但未检测到工程时窗口显示空状态,入口不消失;停用/卸载插件后窗口与命令同步撤销(复用 `declarative-plugin-ui.ts` 的撤销模式)。 + +### 安全调度:每个操作的授权链 + +所有构建执行(原生与网页、加载与运行)在启动前依次通过: + +1. **M1 信任闸门**:包当前处于已安装、已接受、已启用状态,版本字节与安装记录一致。M4 只定义调用点与失败码,闸门实现与持久化 schema 属 M1(见下节接口清单),M4 不并行定义第二套安装记录。 +2. **生命周期隔离**:进程存续期间持有 `lifecycle.rs:37` 的 `NativeLease`(`acquire(plugin_id)`),停用/替换/卸载时 `lifecycle.rs:27` 的 `blocked` 立即拒绝新操作,等待既有使用者退出后再动包。 +3. **调用者身份**:原生调用校验 `(宿主窗口 label, 工作区 ID)` 及当前工作区代次;网页调用经 `with_page_owner` 校验页面归属、页面代次与工作区代次(`web.rs:80-98`),页面必须属于同一插件。 +4. **操作登记**:唯一 operation ID;同槽并发是忙碌冲突(返回可诊断错误,不排队覆盖);跨窗口互不可见、互不可取消。 +5. **参数与 cwd 校验**:cwd 是工作区内解析后的实例根(拒绝 junction/reparse point 逃逸,沿用 `launch.rs` 包路径闸门语义);只执行 Core 计划产出的可执行文件与 argv;环境变量只放行声明过的 `environmentInputs`、运行时管理的 PATH 前置与既有 `LITHE__PATH` 约定,不做任意透传。 +6. **最终启动复检**:启动前再查一次槽位仍有效、代次未变、插件未被 `blocked`、扫描缓存中包版本未变。任一失败按过期处理,不启动。 + +取消与迟到结果的语义照抄 macOS 已验证的规则(构建契约 Note §10"取消后谁能写回"):完成回调同时校验工作区代次、槽位 operation ID 与取消状态,只有仍持有槽的任务能写回模型/依赖/诊断;取消后仍在排空的输出在追加前检查归属;每个旧任务负责清理自己的临时输出文件。停止(用户点停止、窗口关闭、工作区切换、插件停用)必须等待子进程树(Job Object 兜底)与运行管线登记项全部结束,只从列表移除不算完成。`processJobTree` 与共享 Core 生命周期全部保留,不新增绕过 Job Object 的执行路径。 + +### 网页桥 build.* 语义 + +`web.rs` 现有拒绝列表(`:787`)替换为转发实现:`build.instances/setOptions/dependencies` 是读与选项写,走登记表;`build.refresh/runTask/runTests` 是执行,与原生同一条授权链,网页不获得任何额外信任;`build.cancel` 只能取消本页面发起(或本页面归属窗口发起)的操作。等待同意也占用并发名额,取消先于许可到达时操作不得再启动。页面关闭、同包导航、工作区切换、插件停用时,该页面的在途构建操作全部取消并丢弃迟到回复(复用现有页面撤销机制)。 + +### 正确做法 + +- 插件网页选择自己已加载模型中的任务 → 桥校验页面归属 → 登记表校验代次与同意 → `build.taskPlan` 生成 argv → 运行管线启动 → 输出回该页面与 Build Output。 +- 用户把 Maven settings 路径选到不存在文件 → `executableResolution` 的 setting 来源报终止失败,模型/依赖加载停止并给出可诊断错误,不回退 PATH。 +- 停用插件 → `blocked` 拒绝新操作 → 已注册 `NativeLease` 的构建进程等待退出 → 重新扫描 → 工具窗口撤销。 + +### 不要这样做 + +- 在 `build.rs`、resolver 或 TS service 里写 `if buildSystemID == "maven"`、`== "gradle"`;生态知识只在 Core。 +- 让网页把 `executable`、任意工作区路径或 argv 交给通用进程启动命令,即使页面已通过 CSP 与来源检查。 +- 在能力未完整验收前把 `buildSystems` 加进 `host_capabilities`,或把 `build-system-plugins` 矩阵改成 implemented;partial 与 pending 如实记录。 +- 为构建另写一套安装记录、信任状态或 `host_capabilities` 数据源。 + +## M1 提供的输入接口(M4 只消费,不定义) + +M1(PR #1476,head `3408f781`,主审中)独占签名、安装记录 schema、`plugin_host` 的 store/scan/install/launch 生产路径、Cargo 依赖、`declarative-plugin-contract` 矩阵与 Windows 宿主 owning Note。M4 依赖以下接口,实现 PR 必须等 M1 合入后基于最终形态接线: + +| M1 接口 | M4 用途 | 消费方式 | +| --- | --- | --- | +| 扫描缓存中的包状态(现有 `CatalogEntry.status/enabled`,M1 扩展验证记录) | 执行前信任闸门:已安装、已接受、已启用、当前版本字节一致 | 只读查询,不缓存跨操作副本 | +| 发布者验签与全包摘要结果(M1 安装事务产物) | 闸门判断"通过 M1 验证";web 桥与原生同源 | 只读 | +| 安装审查的已批准程序/权限记录(含 command 模型命令、任务/测试命令、打包运行时) | `projectFile`/`command` 执行的同意来源 | 只读;不通过审查的程序不执行 | +| `lifecycle.rs` 隔离 API(`blocked/acquire/NativeLease`) | 构建进程注册为原生使用者 | 直接调用,等待语义以 M1 最终版为准 | +| `host_capabilities`(`mod.rs:37`)与 `declarative-plugin-contract` 矩阵 | M4 最后一 PR 声明 `buildSystems:[1,2]`、`executableResolution:[1]` | 同一时间只允许一个 PR 修改;在 M1 矩阵 PR 之后串行 | + +签名与安装事务的任何并行变更都在 M1 的 PR 里进行;M4 出现"需要 M1 新增接口"的情况时,先在本 Note 的验收标准里补依赖记录,再由 M1 落地,不在 M4 PR 里改 store/install。 + +## 分批 PR 范围与验收 + +按路线图"模型/解析;任务测试/统一桥"切三批,每批一个聚焦 PR,都默认 Draft: + +**PR-A 只读检测与展示(static 线)** +- 新增 `plugin_host/build.rs`(登记表 + detect + static 模型 + `resolveOptions` + `syncFingerprint` 监听 + 选项持久化)与 `features/build-systems/` TS service、工具窗口注册、空状态。 +- 验收:build-static-file fixture 经真实 Core 识别并展示;选项修改经 Core 校验后持久化且 `dropped` 改存生效值;指纹变化后窗口显示"需要重新加载";重新打开工作区选项保留。能力保持关闭,用宿主测试直接带 `hostCapabilities` payload 调 Core 验证。 +- 有界测试:登记表并发用受控假执行器(无真实 sleep);resolver 暂不涉及。 + +**PR-B 进程执行与 Maven 对照** +- 新增 `build_resolver.rs`(五种候选来源解析、`checkToolVersion`、终止失败语义);command/hostIntegration 模型加载;task/test 经运行管线执行;依赖加载;`decodeDiagnostics/decodeTestResults` 进 Build Output;取消/期限/输出上限/进程树等待;`languageImport` 接 Java 启动链。 +- 验收:build-command-stdout、build-command-output-file、build-host-integration fixture 的宿主行为覆盖;Maven 专用流程回归不退化;取消加载与等待同意时切项目的场景通过;配置路径带空格、失效 setting 值为终止失败。 +- 有界测试:进程用固定夹具可执行文件与可控完成顺序;迟到输出与旧代次写回拒绝;Job Object 树结束断言进程不存在而非仅列表移除。 + +**PR-C executableResolution、统一桥与能力开放** +- `executableResolution:1` 完整语义 + `buildSystems:2` 的 `executableSetting` 接受;`web.rs` 8 个 `build.*` 方法经登记表实现;跨窗口、页面关闭、过期代次、权限变化(停用/更新/回滚/卸载期间)场景收口;`host_capabilities` 增加 `buildSystems:[1,2]`、`executableResolution:[1]`;矩阵与契约文档同 PR 更新(`declarative-plugin-contract` 归 M1,先协调再动)。 +- 验收:build-tool-path fixture 端到端(含 setting 显式无效终止失败);至少一个真实声明式构建插件完成保存、同步、模型、运行/测试、停止、重新打开;网页与原生走同一生产链(同一实例状态、同一忙碌冲突、同一取消);矩阵更新后 `verify-platform-feature-matrix-change` 通过。 +- 有界测试:桥的跨 owner/过期代次/取消竞态;能力门控测试随声明更新(参照 settings-v2 fixture 被接受的现有模式)。 + +## 场景覆盖要求(不允许只写 happy path) + +每个实现 PR 的测试与验收必须包含:执行失败(可执行文件缺失、非零退出、输出超限、解码失败);取消(用户取消、窗口关闭、工作区切换、插件停用)及取消后迟到输出不写入新状态;跨窗口互不干扰与各自的忙碌冲突;过期结果(代次变化后的迟到完成只标过期);权限变化(M1 停用/更新/回滚/卸载期间 `blocked`、包字节替换后旧操作不得重启);升级/卸载后工具窗口撤销、登记表清理、进程树确认结束。 + +## 考虑过的备选方案 + +### 构建协调放在主窗口 TS service,网页走定向请求/回复通道 + +最有吸引力的地方:与现有 React 功能模型分层一致,调试直观。不采用:web 桥当前没有可信任的定向回复路由(页面不能选择回复目标),需要新发明一套窗口间协议;而 Rust 已有信任、生命周期与进程能力,路线图 §2.2 明确允许这种情况下把协调器放 Rust。 + +### 每个 `build.*` 桥方法在 web.rs 独立实现授权与取消 + +最有吸引力的地方:改动局部、互不影响。不采用:会出现两套授权链和取消语义,最终启动复检很容易漏掉其中一套,这正是 M4 要消灭的分裂。 + +### 提前声明 buildSystems 能力,用真实包做端到端验证 + +最有吸引力的地方:真实包驱动开发,验收早。不采用:违反路线图"完整实现后声明相应版本";capability 一旦声明,市场就会列出仅部分可用的插件。分批期间用 fixture 驱动的宿主测试(带显式 `hostCapabilities` payload 调 Core),能力开放集中在 PR-C。 + +### 构建任务绕过运行管线,在 build.rs 自建执行器 + +最有吸引力的地方:参数可控,少一层归属映射。不采用:重复实现停止、进程树清理、按窗口清点与关闭兜底;运行管线已有 `WindowRunProcess` 与 Job Object 语义,构建任务复用它只登记归属即可。 + +### 保留现有 Maven 专用流程作为构建系统的展示前端 + +最有吸引力的地方:Maven 界面已存在,直接把声明式数据喂进去最快。不采用:`features/maven/` 的类型与界面按 Maven 语义写死,通用模型(多实例、通用选项、任意任务分组)塞不进去;它保留为独立回归基线,声明式界面按通用模型新建。 + +## 验收标准 + +- 14 个 `build.*` 命令在 Windows 宿主全部有真实接线,Core 语义零复制(评审对照 `commands.rs` 与 `build.rs` 调用点)。 +- 三种非 Provider 模型(static/command/hostIntegration)各有 fixture 级宿主行为验收;`build-tool-path` 覆盖 `executableResolution:1`,`executableSetting` 覆盖 `buildSystems:2`。 +- 原生与网页两通道经同一登记表,授权链六步齐全;本 Note 场景清单全部有测试或验收证据。 +- `build-system-plugins` 矩阵 Windows 侧在 PR-C 后如实更新(implemented + pending,验证完成前不得记 verified);`plugin-web-tool-windows` 的 build 方法部分同步;`declarative-plugin-contract` 由 M1 归属协调。 +- 本 Note 状态在 M4 三批 PR 全部合入并验收后,按 agent-notes 规范迁移为 implemented,并把"构建协调在 Rust"的决策并入 Windows 宿主 owning Note 或在其 owning Note 增补链接。 + +## 风险 + +- M1 主审中且有信任链/事务问题待修复:M4 的 PR-A/B 可以在独立分支准备代码与测试(不依赖新接口的部分),但接线 PR 与能力开放必须等 M1 合入后的最终形态,避免基于未合入实现假设。 +- 运行管线复用需要把构建归属映射进 `RunProcessManager` 的现有所有权模型(按窗口 + 源),若其停止语义与构建槽位冲突,需要在 PR-B 内聚焦调整,不外溢到无关运行功能。 +- WebView2 网页通道的取消时机依赖页面生命周期回调,跨窗口场景需要真实双窗口验收;单测只能覆盖状态机。 +- Maven 双路径并存期间,用户可见两个 Maven 入口(专用流程与声明式窗口),需在 PR-B 的界面文案与管理页分组中明确区分,遵循现有双模型分组模式。 + +## 验证 + +本轮是设计文档,仅运行: + +```bash +node scripts/verify-agent-notes.mjs +``` + +实现 PR 各自的验证范围(本 Note 只规定,不代跑): + +```powershell +cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml plugin_host +# 工作目录 windows/tauri +bun run test -- features/build-systems +./scripts/verify-windows-boundaries.ps1 +node scripts/generate-platform-feature-matrix.mjs --check +node scripts/verify-platform-feature-matrix-change.mjs +``` + +本轮未运行 Windows 构建或应用实例(与 M1 任务避免资源争抢),未修改任何产品代码、共享契约、矩阵或宿主模块;上述结论的源码定位均在基线 `1e78b59b` 核对。 + +## 适用范围 + +本 Note 为 M4 设计真源;下列文件为设计指向的落点(本轮全部未修改): + +- 拟新增:`windows/tauri/src-tauri/src/plugin_host/build.rs`、`build_registry.rs`、`build_resolver.rs`;`windows/tauri/src/features/build-systems/` +- 拟修改(M4 PR 内):`windows/tauri/src-tauri/src/plugin_host/mod.rs`(`host_capabilities`)、`web.rs`(build.* 转发)、`main.rs`(命令注册)、`plugin_host/settings.rs`(只读复用)、`run.rs`(归属登记) +- 参考实现:`macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift`、`macos/Sources/Lithe/Platform/MacOS/Build/`、`macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift`、`macos/Sources/Lithe/Views/Workbench/BuildSystemToolWindowRegistration.swift` +- 契约与依赖:`rust/lithe-core/src/build/commands.rs`、`rust/lithe-core/src/plugins/{capabilities,executables}.rs`、`rust/lithe-core/src/build/manifest.rs`、`shared/contracts/plugins/v1/`(fixtures `build-static-file`、`build-command-stdout`、`build-command-output-file`、`build-host-integration`、`build-tool-path`、`build-plans.json`)、[构建系统插件契约](../../implemented/architecture/2026-10-17-build-system-plugin-contract.md)、[Windows 声明式插件宿主](../../implemented/architecture/2026-10-22-windows-declarative-plugin-host.md)、[Windows 插件路线图](../../../../docs/development/windows-plugin-parity-plan.md) diff --git a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 index c5352e23da..77a011bfd3 100644 --- a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 +++ b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 @@ -111,6 +111,11 @@ function Invoke-TimedRustTests { # 287 Git processes; this integration case measured 49.9s on Windows. $arguments += @("--test-budget", "tests::git_workspace_commit::git_workspace_commit_push_confirms_each_repository_separately=60000") } + if (-not $Package -and $Manifest -eq "windows/tauri/src-tauri/Cargo.toml") { + # This real-Git integration case took 14.4s locally, while its + # repository initialization exceeded the former 5s Core deadline. + $arguments += @("--test-budget", "platform::tests::lightweight_tag_overrides_default_signing_without_running_signer_or_editor=30000") + } return $arguments } diff --git a/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs b/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs index 9bb7366f9b..16e741f018 100755 --- a/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs +++ b/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs @@ -111,6 +111,26 @@ const typescriptViolations = scanFile( ); assert.deepEqual(typescriptViolations.map((violation) => violation.rule), ["typescript-real-timer"]); +// Browser fixtures must obey the same timer rules, including owned watchdog exceptions. +for (const extension of ["ts", "tsx"]) { + const browserTimerContent = `setTimeout(observe, 10); +// test-stability: allow(typescript-real-timer) reason: Browser frames may stop; an owned watchdog bounds observation and is cancelled in cleanup. +setTimeout(failObservation, 1000); +`; + const browserViolations = scanFile( + `windows/tauri/src/example.browser-test.${extension}`, + browserTimerContent, + ); + assert.deepEqual(browserViolations.map(({ rule, line }) => ({ rule, line })), [ + { rule: "typescript-real-timer", line: 1 }, + ]); + assert.deepEqual(scanFile( + `windows/tauri/src/example.browser-test.${extension}`, + browserTimerContent, + new Set([3]), + ), []); +} + const rustViolations = scanFile( "rust/lithe-core/src/example.rs", `use std::thread; diff --git a/.agents/skills/write-stable-tests/scripts/verify-test-stability.mjs b/.agents/skills/write-stable-tests/scripts/verify-test-stability.mjs index 7a644f5657..cd9db722a5 100755 --- a/.agents/skills/write-stable-tests/scripts/verify-test-stability.mjs +++ b/.agents/skills/write-stable-tests/scripts/verify-test-stability.mjs @@ -74,7 +74,7 @@ function languageFor(filePath) { ) { return "swift"; } - if (/\.(?:test|spec)\.tsx?$/.test(normalized)) return "typescript"; + if (/\.(?:test|spec|browser-test)\.tsx?$/.test(normalized)) return "typescript"; if (normalized.endsWith(".rs")) return "rust"; return null; } diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index ca9f3369b6..df8a9f0c86 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -76,6 +76,35 @@ jobs: writeTestReportArtifacts(`${stem}.json`); NODE + # Declarative-plugin fixture coverage: the directory enumeration and + # the explicit classification list must match, so a fixture added + # without registration fails every pull request on this Linux lane. + - name: Check plugin fixture coverage manifest + timeout-minutes: 2 + run: node scripts/check-plugin-fixture-coverage.mjs + + - name: Run plugin fixture coverage tests + timeout-minutes: 2 + run: | + node --test --test-timeout=15000 \ + --test-reporter=spec --test-reporter-destination=stdout \ + --test-reporter=junit --test-reporter-destination=.artifacts/test-stability/plugin-fixture-coverage.junit.xml \ + scripts/check-plugin-fixture-coverage.test.mjs + + - name: Generate plugin fixture coverage timing report + if: always() + run: | + node --input-type=module <<'NODE' + import fs from 'node:fs'; + import { parseJUnitCases } from './.agents/skills/write-stable-tests/scripts/parse-junit-cases.mjs'; + import { writeTestReportArtifacts } from './.agents/skills/write-stable-tests/scripts/generate-test-report.mjs'; + const stem = '.artifacts/test-stability/plugin-fixture-coverage'; + const tests = parseJUnitCases(fs.readFileSync(`${stem}.junit.xml`, 'utf8')); + if (tests.length === 0) throw new Error('Missing plugin fixture coverage test results'); + fs.writeFileSync(`${stem}.json`, JSON.stringify({ runner: 'node', warnMs: 1000, maxMs: 15000, tests })); + writeTestReportArtifacts(`${stem}.json`); + NODE + - name: Upload Sparkle download timing report if: always() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index f2348b803e..16377d5b6b 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -47,6 +47,7 @@ jobs: run: | node scripts/test-verify-download-cache.mjs node scripts/test-reuse-worktree-resources.mjs + node --test --test-timeout=15000 scripts/windows-tree-sitter-proxy.test.cjs node --test --test-timeout=15000 scripts/test-codex-config-relay.mjs - name: Resolve comparison base @@ -313,9 +314,179 @@ jobs: node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-plugin-lsp-launch.json -- src/features/editor/lsp/resolve-editor-lsp-launch.test.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test plugin sources details build models and editor admission + shell: pwsh + timeout-minutes: 12 + run: | + $runner = ".agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs" + # Each file owns its native/DOM mocks. Leading ./ selects an exact + # file, so the .ts source service does not also run its .tsx view. + $suites = @( + "src/features/plugins/declarative-marketplace-sources.test.ts", + "src/features/plugins/declarative-marketplace-sources.test.tsx", + "src/features/plugins/declarative-marketplace.test.ts", + "src/features/plugins/marketplace/marketplace-types.test.ts", + "src/features/plugins/marketplace/marketplace-session.test.ts", + "src/features/plugins/marketplace/marketplace-section.test.tsx", + "src/features/plugins/marketplace/marketplace-host-service.test.ts", + "src/features/plugins/details/plugin-details.service.test.ts", + "src/features/plugins/details/plugin-details-view.test.tsx", + "src/features/plugins/declarative-plugin-list-model.test.ts", + "src/features/plugins/declarative-plugins-section.test.tsx", + "src/features/build-systems/build-systems-service.test.ts", + "src/features/build-systems/build-systems-store.test.ts", + "src/features/build-systems/build-run-native.test.ts", + "src/features/build-systems/build-run-session.test.ts", + "src/features/build-systems/build-dependencies-native.test.ts", + "src/features/build-systems/build-dependencies-session.test.ts", + "src/features/build-systems/components/build-dependencies.test.tsx", + "src/features/build-systems/build-language-import.test.ts", + "src/features/maven/services/java-maven-configuration.test.ts", + "src/features/editor/lsp/resolve-editor-lsp-launch.test.ts", + "src/features/plugins/plugin-web-build.test.ts", + "src/features/build-systems/components/build-run-controls.test.tsx", + "src/features/build-systems/components/build-model-controls.test.tsx", + "src/features/build-systems/components/build-system-pane.test.tsx", + "src/features/build-systems/output/build-output-store.test.ts", + "src/features/build-systems/output/build-output-path.test.ts", + "src/features/build-systems/output/components/build-output-pane.test.tsx", + "src/features/build-systems/output/components/build-output-log.test.tsx", + "src/features/build-systems/output/components/build-output-problems.test.tsx", + "src/features/build-systems/output/components/build-output-tests.test.tsx", + "src/features/editor/lsp/built-in-language-support.test.ts", + "src/features/editor/lsp/declarative-language-support.test.ts", + "src/features/editor/hooks/use-lsp-integration-declarative.test.tsx", + "src/features/editor/hooks/use-lsp-attachment-lifecycle.test.tsx", + "src/features/editor/hooks/use-lsp-queued-owner.test.tsx", + "src/features/editor/lsp/lsp-client-attachment-lifecycle.test.ts", + "src/features/editor/stores/buffer-lsp-close.test.ts" + ) + $index = 0 + foreach ($suite in $suites) { + $index += 1 + node $runner --suite-timeout-ms 60000 --report ".artifacts/test-stability/windows-plugin-integration-$index.json" -- "./$suite" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + + - name: Test plugin docking and VCS write boundaries in isolation + shell: pwsh + timeout-minutes: 10 + run: | + $runner = ".agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs" + $suites = @( + "src/ui/search.test.tsx", + "src/ui/info-tip.test.tsx", + "src/ui/icons.test.tsx", + "src/features/plugins/native-ui/native-docking.test.tsx", + "src/features/plugins/native-ui/native-main-layout.test.tsx", + "src/features/plugins/native-ui/native-panel.test.tsx", + "src/features/plugins/native-ui/native-functions.test.ts", + "src/features/layout/components/plugin-activity-rail.test.ts", + "src/features/layout/components/bottom-pane/bottom-pane-terminal-lifecycle.test.tsx", + "src/features/layout/actions/workbench-tool-window-actions.test.ts", + "src/features/layout/actions/right-tool-window-actions.test.ts", + "src/features/layout/utils/sidebar-pane-utils.test.ts", + "src/features/vcs/vcs-resolve-cleanup.test.ts", + "src/features/vcs/vcs-file-operations.test.ts", + "src/features/vcs/vcs-action-controller.test.ts", + "src/features/vcs/vcs-actions-panel.test.tsx" + ) + $index = 0 + foreach ($suite in $suites) { + $index += 1 + node $runner --suite-timeout-ms 60000 --report ".artifacts/test-stability/windows-plugin-docking-vcs-$index.json" -- --preload ./src/test-utils/vite-assets.ts "./$suite" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + + - name: Set up Python for plugin acceptance driver checks + uses: actions/setup-python@v6 + with: + python-version: '3.14' + + - name: Test plugin acceptance process ownership, marketplace and LSP fixtures + shell: pwsh + timeout-minutes: 4 + run: | + python -m pip install --disable-pip-version-check playwright==1.60.0 psutil==7.2.2 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python -B scripts/plugin-host-acceptance/acceptance_driver_test.py --report .artifacts/test-stability/plugin-host-driver.json --dom-browser msedge + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python -B scripts/plugin-host-acceptance/marketplace_acceptance_test.py --report .artifacts/test-stability/plugin-marketplace-driver.json --dom-browser msedge + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python -B scripts/plugin-host-acceptance/lsp_lifecycle_acceptance_test.py --report .artifacts/test-stability/plugin-lsp-lifecycle-driver.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python -B scripts/plugin-host-acceptance/settings_functions_acceptance_test.py --report .artifacts/test-stability/plugin-settings-functions-driver.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python -B scripts/plugin-host-acceptance/web_bridge_fallback_acceptance_test.py --report .artifacts/test-stability/plugin-web-fallback-helpers.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python -B scripts/plugin-host-acceptance/text_window_acceptance_test.py --report .artifacts/test-stability/plugin-text-window-helpers.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python -B scripts/plugin-host-acceptance/vcs_move_diagnostic_test.py --report .artifacts/test-stability/plugin-vcs-move-driver.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node scripts/test-plugin-vcs-move-diagnostic.mjs --report .artifacts/test-stability/plugin-vcs-move-node.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Test offline Windows build preparation + shell: pwsh + timeout-minutes: 4 + run: | + @' + import json + import sys + import time + from pathlib import Path + sys.path.insert(0, str(Path('scripts/plugin-host-acceptance').resolve())) + from acceptance_driver import run_bounded_process + report = Path('.artifacts/test-stability/plugin-build-preflight.json') + report.parent.mkdir(parents=True, exist_ok=True) + report.write_text(json.dumps({'runner': 'acceptance', 'maxMs': 190000, 'tests': [{'name': 'offline preparation pending completion', 'status': 'incomplete', 'durationMs': 0}]}), encoding='utf-8') + started = time.perf_counter() + try: + result = run_bounded_process([sys.executable, '-B', 'scripts/plugin-host-acceptance/build_preflight_test.py', '--report', str(report)], 190) + print(result.stdout, end='') + print(result.stderr, end='', file=sys.stderr) + code = result.returncode + if code != 0: + evidence = json.loads(report.read_text(encoding='utf-8')) + evidence['tests'].append({'name': 'offline preparation process result', 'status': 'failed', 'durationMs': (time.perf_counter()-started)*1000, 'message': f'process exited {code}'}) + report.write_text(json.dumps(evidence, indent=2), encoding='utf-8') + except Exception as error: + evidence = json.loads(report.read_text(encoding='utf-8')) + evidence['tests'].append({'name': 'offline preparation process owner', 'status': 'failed', 'durationMs': (time.perf_counter()-started)*1000, 'message': str(error)}) + report.write_text(json.dumps(evidence, indent=2), encoding='utf-8') + print(error, file=sys.stderr) + code = 1 + raise SystemExit(code) + '@ | python -B - + $preflightExit = $LASTEXITCODE + node --input-type=module -e "import {writeTestReportArtifacts} from './.agents/skills/write-stable-tests/scripts/generate-test-report.mjs'; writeTestReportArtifacts('.artifacts/test-stability/plugin-build-preflight.json');" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($preflightExit -ne 0) { exit $preflightExit } + + - name: Test native plugin controls, workbench, tree menus and lifecycle in Edge + shell: pwsh + timeout-minutes: 12 + run: | + $playwrightModule = python -c "from pathlib import Path; import playwright; print(Path(playwright.__file__).parent / 'driver/package/index.mjs')" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node scripts/test-windows-native-plugin-ui-browser.mjs --browser msedge --playwright-module $playwrightModule --timeout-ms 120000 --report .artifacts/test-stability/native-panel-browser.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node scripts/test-windows-native-plugin-ui-browser.mjs --mode native-tree --browser msedge --playwright-module $playwrightModule --timeout-ms 120000 --report .artifacts/test-stability/native-tree-browser.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node scripts/test-windows-native-plugin-ui-browser.mjs --mode form-grid --browser msedge --playwright-module $playwrightModule --timeout-ms 120000 --report .artifacts/test-stability/native-form-grid-browser.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node scripts/test-windows-native-plugin-ui-browser.mjs --mode controls-input --browser msedge --playwright-module $playwrightModule --timeout-ms 120000 --report .artifacts/test-stability/native-controls-input-browser.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node scripts/test-windows-native-plugin-ui-browser.mjs --mode workbench --browser msedge --playwright-module $playwrightModule --timeout-ms 120000 --report .artifacts/test-stability/native-workbench-browser.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + foreach ($failure in @('worker-exit', 'worker-hang')) { + node scripts/test-windows-native-plugin-ui-browser.mjs --mode lifecycle --inject-failure $failure --timeout-ms 25000 --report ".artifacts/test-stability/native-browser-$failure.json" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + - name: Test Agent panel and capability gate shell: pwsh - run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-agent.json -- src/features/agent src/features/editor/stores/buffer-agent-conversation.test.ts src/utils/markdown-links.test.ts src/config/backend-capabilities.test.ts src/i18n src/features/file-system/utils/file-system-drop-controller.test.ts src/ui/icons.test.tsx + run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-agent.json -- src/features/agent src/features/editor/stores/buffer-agent-conversation.test.ts src/utils/markdown-links.test.ts src/config/backend-capabilities.test.ts src/i18n src/features/file-system/utils/file-system-drop-controller.test.ts - name: Test Agent connection service ownership in isolation shell: pwsh @@ -643,6 +814,11 @@ jobs: node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-spring-index-readiness.json -- src/features/spring/hooks/use-spring-index.test.tsx if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Generate combined Windows frontend test report + if: always() + shell: pwsh + run: node .agents/skills/write-stable-tests/scripts/generate-test-report.mjs + - name: Upload Windows frontend test reports if: always() uses: actions/upload-artifact@v4 @@ -744,6 +920,12 @@ jobs: timeout-minutes: 20 run: ./.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 -Scope WindowsRust -SuiteTimeoutSeconds 1080 + - name: Test plugin VCS through real Core stdio + if: needs.changes.outputs.windows_rust == 'true' + shell: pwsh + timeout-minutes: 20 + run: node scripts/test-windows-plugin-vcs-core.mjs + - name: Generate combined Windows Rust test report if: always() shell: pwsh diff --git a/bun.lock b/bun.lock index 1d7c2ef02e..56de045594 100644 --- a/bun.lock +++ b/bun.lock @@ -146,2118 +146,2118 @@ }, }, "packages": { - "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.15.0", "https://registry.npmmirror.com/@apm-js-collab/code-transformer/-/code-transformer-0.15.0.tgz", { "dependencies": { "@types/estree": "^1.0.8", "astring": "^1.9.0", "esquery": "^1.7.0", "meriyah": "^6.1.4", "semifies": "^1.0.0", "source-map": "^0.6.0" }, "bin": { "code-transformer": "cli.js" } }, "sha512-XmXYVs8CzJ1Aj79noVbn2weUO/XWtRyURpGqx7aU7DOXlUQhR0WKOQNF0okh7PCeY37vxf7kU3v57OAkEPm3ww=="], + "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.15.0", "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.15.0.tgz", { "dependencies": { "@types/estree": "^1.0.8", "astring": "^1.9.0", "esquery": "^1.7.0", "meriyah": "^6.1.4", "semifies": "^1.0.0", "source-map": "^0.6.0" }, "bin": { "code-transformer": "cli.js" } }, "sha512-XmXYVs8CzJ1Aj79noVbn2weUO/XWtRyURpGqx7aU7DOXlUQhR0WKOQNF0okh7PCeY37vxf7kU3v57OAkEPm3ww=="], - "@apm-js-collab/code-transformer-bundler-plugins": ["@apm-js-collab/code-transformer-bundler-plugins@0.5.0", "https://registry.npmmirror.com/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.5.0.tgz", { "dependencies": { "@apm-js-collab/code-transformer": "^0.15.0", "es-module-lexer": "^2.1.0", "magic-string": "^0.30.21", "module-details-from-path": "^1.0.4" } }, "sha512-YxLBY5nGlurL7QeJLq6e5g0ouBpAp0pwgyA/5rHXEXwhiPLn9ZHbT+Y2LlP90GT872cSocfjWRYu/fnpuBudNQ=="], + "@apm-js-collab/code-transformer-bundler-plugins": ["@apm-js-collab/code-transformer-bundler-plugins@0.5.0", "https://registry.npmjs.org/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.5.0.tgz", { "dependencies": { "@apm-js-collab/code-transformer": "^0.15.0", "es-module-lexer": "^2.1.0", "magic-string": "^0.30.21", "module-details-from-path": "^1.0.4" } }, "sha512-YxLBY5nGlurL7QeJLq6e5g0ouBpAp0pwgyA/5rHXEXwhiPLn9ZHbT+Y2LlP90GT872cSocfjWRYu/fnpuBudNQ=="], - "@apm-js-collab/tracing-hooks": ["@apm-js-collab/tracing-hooks@0.10.0", "https://registry.npmmirror.com/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.10.0.tgz", { "dependencies": { "@apm-js-collab/code-transformer": "^0.15.0", "debug": "^4.4.1", "module-details-from-path": "^1.0.4" } }, "sha512-2/Z3NTewJTruUkmsSnBC5bJlLNUd9keuD1OLlTEpim4FyLhm6m2Rnfv+wrFdUvFfhmH8CRdiDZBqBrn+wyaGuA=="], + "@apm-js-collab/tracing-hooks": ["@apm-js-collab/tracing-hooks@0.10.0", "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.10.0.tgz", { "dependencies": { "@apm-js-collab/code-transformer": "^0.15.0", "debug": "^4.4.1", "module-details-from-path": "^1.0.4" } }, "sha512-2/Z3NTewJTruUkmsSnBC5bJlLNUd9keuD1OLlTEpim4FyLhm6m2Rnfv+wrFdUvFfhmH8CRdiDZBqBrn+wyaGuA=="], - "@babel/code-frame": ["@babel/code-frame@7.29.7", "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "@babel/compat-data": ["@babel/compat-data@7.29.7", "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + "@babel/compat-data": ["@babel/compat-data@7.29.7", "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - "@babel/core": ["@babel/core@7.29.7", "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + "@babel/core": ["@babel/core@7.29.7", "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], - "@babel/generator": ["@babel/generator@7.29.7", "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.7.tgz", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + "@babel/generator": ["@babel/generator@7.29.7", "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], - "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], - "@babel/helpers": ["@babel/helpers@7.29.7", "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.7.tgz", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + "@babel/helpers": ["@babel/helpers@7.29.7", "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - "@babel/parser": ["@babel/parser@7.29.7", "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@babel/parser": ["@babel/parser@7.29.7", "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "@babel/runtime": ["@babel/runtime@7.29.2", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], - "@babel/template": ["@babel/template@7.29.7", "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + "@babel/template": ["@babel/template@7.29.7", "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - "@babel/traverse": ["@babel/traverse@7.29.7", "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.7.tgz", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + "@babel/traverse": ["@babel/traverse@7.29.7", "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], - "@babel/types": ["@babel/types@7.29.7", "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "@babel/types": ["@babel/types@7.29.7", "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@base-ui/react": ["@base-ui/react@1.6.0", "https://registry.npmmirror.com/@base-ui/react/-/react-1.6.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.3.1", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw=="], + "@base-ui/react": ["@base-ui/react@1.6.0", "https://registry.npmjs.org/@base-ui/react/-/react-1.6.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.3.1", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw=="], - "@base-ui/utils": ["@base-ui/utils@0.3.1", "https://registry.npmmirror.com/@base-ui/utils/-/utils-0.3.1.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg=="], + "@base-ui/utils": ["@base-ui/utils@0.3.1", "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.1.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg=="], - "@blazediff/core": ["@blazediff/core@1.9.1", "https://registry.npmmirror.com/@blazediff/core/-/core-1.9.1.tgz", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], + "@blazediff/core": ["@blazediff/core@1.9.1", "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], - "@code-inspector/core": ["@code-inspector/core@1.6.2", "https://registry.npmmirror.com/@code-inspector/core/-/core-1.6.2.tgz", { "dependencies": { "@vue/compiler-dom": "^3.5.13", "chalk": "^4.1.1", "dotenv": "^16.1.4", "launch-ide": "1.4.3", "portfinder": "^1.0.28" } }, "sha512-9VmQN16BQWWMNm/QiV9z/deFoWiCsTvPwBfvDHOxU//ew99W1+u9uQIhy7KncNCDlTgem2D/cZTShqPKN4itTw=="], + "@code-inspector/core": ["@code-inspector/core@1.6.2", "https://registry.npmjs.org/@code-inspector/core/-/core-1.6.2.tgz", { "dependencies": { "@vue/compiler-dom": "^3.5.13", "chalk": "^4.1.1", "dotenv": "^16.1.4", "launch-ide": "1.4.3", "portfinder": "^1.0.28" } }, "sha512-9VmQN16BQWWMNm/QiV9z/deFoWiCsTvPwBfvDHOxU//ew99W1+u9uQIhy7KncNCDlTgem2D/cZTShqPKN4itTw=="], - "@code-inspector/esbuild": ["@code-inspector/esbuild@1.6.2", "https://registry.npmmirror.com/@code-inspector/esbuild/-/esbuild-1.6.2.tgz", { "dependencies": { "@code-inspector/core": "1.6.2" } }, "sha512-YzwIBlNIyOTLS6uI6sYxTDMkbRJ+u2QB25z8luIx7jLiK3TWZu+NA1sd2tB4p/TNy1SKZ2+MsjNanEuXaOinqw=="], + "@code-inspector/esbuild": ["@code-inspector/esbuild@1.6.2", "https://registry.npmjs.org/@code-inspector/esbuild/-/esbuild-1.6.2.tgz", { "dependencies": { "@code-inspector/core": "1.6.2" } }, "sha512-YzwIBlNIyOTLS6uI6sYxTDMkbRJ+u2QB25z8luIx7jLiK3TWZu+NA1sd2tB4p/TNy1SKZ2+MsjNanEuXaOinqw=="], - "@code-inspector/mako": ["@code-inspector/mako@1.6.2", "https://registry.npmmirror.com/@code-inspector/mako/-/mako-1.6.2.tgz", { "dependencies": { "@code-inspector/core": "1.6.2" } }, "sha512-d1Lk6+L0OS0xfyqOSIkF9+z3RfatVQx8zTnVZBgHhpKBzE7p/fROKlGhdGK7uMstHxlAUlsO2+ykNP3jPGmHWw=="], + "@code-inspector/mako": ["@code-inspector/mako@1.6.2", "https://registry.npmjs.org/@code-inspector/mako/-/mako-1.6.2.tgz", { "dependencies": { "@code-inspector/core": "1.6.2" } }, "sha512-d1Lk6+L0OS0xfyqOSIkF9+z3RfatVQx8zTnVZBgHhpKBzE7p/fROKlGhdGK7uMstHxlAUlsO2+ykNP3jPGmHWw=="], - "@code-inspector/turbopack": ["@code-inspector/turbopack@1.6.2", "https://registry.npmmirror.com/@code-inspector/turbopack/-/turbopack-1.6.2.tgz", { "dependencies": { "@code-inspector/core": "1.6.2", "@code-inspector/webpack": "1.6.2" } }, "sha512-rWeFxJxVH8mhApMCmvpzYLpBOxNTbZkxCTRAm9mKEm/YvMpghg1Y8EBXD9EezyQKia2biBIoLAnlB29TJsXawg=="], + "@code-inspector/turbopack": ["@code-inspector/turbopack@1.6.2", "https://registry.npmjs.org/@code-inspector/turbopack/-/turbopack-1.6.2.tgz", { "dependencies": { "@code-inspector/core": "1.6.2", "@code-inspector/webpack": "1.6.2" } }, "sha512-rWeFxJxVH8mhApMCmvpzYLpBOxNTbZkxCTRAm9mKEm/YvMpghg1Y8EBXD9EezyQKia2biBIoLAnlB29TJsXawg=="], - "@code-inspector/vite": ["@code-inspector/vite@1.6.2", "https://registry.npmmirror.com/@code-inspector/vite/-/vite-1.6.2.tgz", { "dependencies": { "@code-inspector/core": "1.6.2", "chalk": "4.1.1" } }, "sha512-JRlxN+EKe2k3SMDimvAFkpQumZuhZOX68tiSnyg1wBz1gFNQYNUVlK/BYMkPTeeFu1rJcFGMwQNWw9lBaFsvEg=="], + "@code-inspector/vite": ["@code-inspector/vite@1.6.2", "https://registry.npmjs.org/@code-inspector/vite/-/vite-1.6.2.tgz", { "dependencies": { "@code-inspector/core": "1.6.2", "chalk": "4.1.1" } }, "sha512-JRlxN+EKe2k3SMDimvAFkpQumZuhZOX68tiSnyg1wBz1gFNQYNUVlK/BYMkPTeeFu1rJcFGMwQNWw9lBaFsvEg=="], - "@code-inspector/webpack": ["@code-inspector/webpack@1.6.2", "https://registry.npmmirror.com/@code-inspector/webpack/-/webpack-1.6.2.tgz", { "dependencies": { "@code-inspector/core": "1.6.2" } }, "sha512-eFwDZjLH83Pp3tl+tor7Zvc40A54mGB5Ybh/g8/y8s9991Y9eznaBhfM5IHZczXUeeFqkqCi2I8u6TbBTMPUpw=="], + "@code-inspector/webpack": ["@code-inspector/webpack@1.6.2", "https://registry.npmjs.org/@code-inspector/webpack/-/webpack-1.6.2.tgz", { "dependencies": { "@code-inspector/core": "1.6.2" } }, "sha512-eFwDZjLH83Pp3tl+tor7Zvc40A54mGB5Ybh/g8/y8s9991Y9eznaBhfM5IHZczXUeeFqkqCi2I8u6TbBTMPUpw=="], - "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="], - "@codemirror/commands": ["@codemirror/commands@6.10.4", "https://registry.npmmirror.com/@codemirror/commands/-/commands-6.10.4.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg=="], + "@codemirror/commands": ["@codemirror/commands@6.10.4", "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg=="], - "@codemirror/lang-angular": ["@codemirror/lang-angular@0.1.4", "https://registry.npmmirror.com/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.3" } }, "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g=="], + "@codemirror/lang-angular": ["@codemirror/lang-angular@0.1.4", "https://registry.npmjs.org/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.3" } }, "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g=="], - "@codemirror/lang-cpp": ["@codemirror/lang-cpp@6.0.3", "https://registry.npmmirror.com/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/cpp": "^1.0.0" } }, "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA=="], + "@codemirror/lang-cpp": ["@codemirror/lang-cpp@6.0.3", "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/cpp": "^1.0.0" } }, "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA=="], - "@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "https://registry.npmmirror.com/@codemirror/lang-css/-/lang-css-6.3.1.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="], + "@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="], - "@codemirror/lang-go": ["@codemirror/lang-go@6.0.1", "https://registry.npmmirror.com/@codemirror/lang-go/-/lang-go-6.0.1.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/go": "^1.0.0" } }, "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg=="], + "@codemirror/lang-go": ["@codemirror/lang-go@6.0.1", "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/go": "^1.0.0" } }, "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg=="], - "@codemirror/lang-html": ["@codemirror/lang-html@6.4.11", "https://registry.npmmirror.com/@codemirror/lang-html/-/lang-html-6.4.11.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw=="], + "@codemirror/lang-html": ["@codemirror/lang-html@6.4.11", "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw=="], - "@codemirror/lang-java": ["@codemirror/lang-java@6.0.2", "https://registry.npmmirror.com/@codemirror/lang-java/-/lang-java-6.0.2.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/java": "^1.0.0" } }, "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ=="], + "@codemirror/lang-java": ["@codemirror/lang-java@6.0.2", "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/java": "^1.0.0" } }, "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ=="], - "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "https://registry.npmmirror.com/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="], + "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="], - "@codemirror/lang-jinja": ["@codemirror/lang-jinja@6.0.1", "https://registry.npmmirror.com/@codemirror/lang-jinja/-/lang-jinja-6.0.1.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.4.0" } }, "sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw=="], + "@codemirror/lang-jinja": ["@codemirror/lang-jinja@6.0.1", "https://registry.npmjs.org/@codemirror/lang-jinja/-/lang-jinja-6.0.1.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.4.0" } }, "sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw=="], - "@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "https://registry.npmmirror.com/@codemirror/lang-json/-/lang-json-6.0.2.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="], + "@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="], - "@codemirror/lang-less": ["@codemirror/lang-less@6.0.2", "https://registry.npmmirror.com/@codemirror/lang-less/-/lang-less-6.0.2.tgz", { "dependencies": { "@codemirror/lang-css": "^6.2.0", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ=="], + "@codemirror/lang-less": ["@codemirror/lang-less@6.0.2", "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz", { "dependencies": { "@codemirror/lang-css": "^6.2.0", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ=="], - "@codemirror/lang-liquid": ["@codemirror/lang-liquid@6.3.2", "https://registry.npmmirror.com/@codemirror/lang-liquid/-/lang-liquid-6.3.2.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw=="], + "@codemirror/lang-liquid": ["@codemirror/lang-liquid@6.3.2", "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.3.2.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw=="], - "@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.1", "https://registry.npmmirror.com/@codemirror/lang-markdown/-/lang-markdown-6.5.1.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-6re5avCNfyRMIoi3XNjbEfQM1vTeVD3JS3g/Fyegyso/eoANFM71Cyvbb66LDyYtQLMEcRFlzioywCqDo9SlLA=="], + "@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.1", "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.1.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-6re5avCNfyRMIoi3XNjbEfQM1vTeVD3JS3g/Fyegyso/eoANFM71Cyvbb66LDyYtQLMEcRFlzioywCqDo9SlLA=="], - "@codemirror/lang-php": ["@codemirror/lang-php@6.0.2", "https://registry.npmmirror.com/@codemirror/lang-php/-/lang-php-6.0.2.tgz", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/php": "^1.0.0" } }, "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA=="], + "@codemirror/lang-php": ["@codemirror/lang-php@6.0.2", "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/php": "^1.0.0" } }, "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA=="], - "@codemirror/lang-python": ["@codemirror/lang-python@6.2.1", "https://registry.npmmirror.com/@codemirror/lang-python/-/lang-python-6.2.1.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.3.2", "@codemirror/language": "^6.8.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/python": "^1.1.4" } }, "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw=="], + "@codemirror/lang-python": ["@codemirror/lang-python@6.2.1", "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.3.2", "@codemirror/language": "^6.8.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/python": "^1.1.4" } }, "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw=="], - "@codemirror/lang-rust": ["@codemirror/lang-rust@6.0.2", "https://registry.npmmirror.com/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/rust": "^1.0.0" } }, "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA=="], + "@codemirror/lang-rust": ["@codemirror/lang-rust@6.0.2", "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/rust": "^1.0.0" } }, "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA=="], - "@codemirror/lang-sass": ["@codemirror/lang-sass@6.0.2", "https://registry.npmmirror.com/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz", { "dependencies": { "@codemirror/lang-css": "^6.2.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/sass": "^1.0.0" } }, "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q=="], + "@codemirror/lang-sass": ["@codemirror/lang-sass@6.0.2", "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz", { "dependencies": { "@codemirror/lang-css": "^6.2.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/sass": "^1.0.0" } }, "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q=="], - "@codemirror/lang-sql": ["@codemirror/lang-sql@6.10.0", "https://registry.npmmirror.com/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w=="], + "@codemirror/lang-sql": ["@codemirror/lang-sql@6.10.0", "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w=="], - "@codemirror/lang-vue": ["@codemirror/lang-vue@0.1.3", "https://registry.npmmirror.com/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug=="], + "@codemirror/lang-vue": ["@codemirror/lang-vue@0.1.3", "https://registry.npmjs.org/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug=="], - "@codemirror/lang-wast": ["@codemirror/lang-wast@6.0.2", "https://registry.npmmirror.com/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q=="], + "@codemirror/lang-wast": ["@codemirror/lang-wast@6.0.2", "https://registry.npmjs.org/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q=="], - "@codemirror/lang-xml": ["@codemirror/lang-xml@6.1.0", "https://registry.npmmirror.com/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/xml": "^1.0.0" } }, "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg=="], + "@codemirror/lang-xml": ["@codemirror/lang-xml@6.1.0", "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/xml": "^1.0.0" } }, "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg=="], - "@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.3", "https://registry.npmmirror.com/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ=="], + "@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.3", "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ=="], - "@codemirror/language": ["@codemirror/language@6.12.4", "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.4.tgz", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A=="], + "@codemirror/language": ["@codemirror/language@6.12.4", "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A=="], - "@codemirror/language-data": ["@codemirror/language-data@6.5.2", "https://registry.npmmirror.com/@codemirror/language-data/-/language-data-6.5.2.tgz", { "dependencies": { "@codemirror/lang-angular": "^0.1.0", "@codemirror/lang-cpp": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-go": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-java": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/lang-jinja": "^6.0.0", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-less": "^6.0.0", "@codemirror/lang-liquid": "^6.0.0", "@codemirror/lang-markdown": "^6.0.0", "@codemirror/lang-php": "^6.0.0", "@codemirror/lang-python": "^6.0.0", "@codemirror/lang-rust": "^6.0.0", "@codemirror/lang-sass": "^6.0.0", "@codemirror/lang-sql": "^6.0.0", "@codemirror/lang-vue": "^0.1.1", "@codemirror/lang-wast": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/legacy-modes": "^6.4.0" } }, "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg=="], + "@codemirror/language-data": ["@codemirror/language-data@6.5.2", "https://registry.npmjs.org/@codemirror/language-data/-/language-data-6.5.2.tgz", { "dependencies": { "@codemirror/lang-angular": "^0.1.0", "@codemirror/lang-cpp": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-go": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-java": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/lang-jinja": "^6.0.0", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-less": "^6.0.0", "@codemirror/lang-liquid": "^6.0.0", "@codemirror/lang-markdown": "^6.0.0", "@codemirror/lang-php": "^6.0.0", "@codemirror/lang-python": "^6.0.0", "@codemirror/lang-rust": "^6.0.0", "@codemirror/lang-sass": "^6.0.0", "@codemirror/lang-sql": "^6.0.0", "@codemirror/lang-vue": "^0.1.1", "@codemirror/lang-wast": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/legacy-modes": "^6.4.0" } }, "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg=="], - "@codemirror/legacy-modes": ["@codemirror/legacy-modes@6.5.3", "https://registry.npmmirror.com/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", { "dependencies": { "@codemirror/language": "^6.0.0" } }, "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg=="], + "@codemirror/legacy-modes": ["@codemirror/legacy-modes@6.5.3", "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", { "dependencies": { "@codemirror/language": "^6.0.0" } }, "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg=="], - "@codemirror/lint": ["@codemirror/lint@6.9.7", "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.7.tgz", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="], + "@codemirror/lint": ["@codemirror/lint@6.9.7", "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="], - "@codemirror/merge": ["@codemirror/merge@6.12.2", "https://registry.npmmirror.com/@codemirror/merge/-/merge-6.12.2.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/highlight": "^1.0.0", "style-mod": "^4.1.0" } }, "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w=="], + "@codemirror/merge": ["@codemirror/merge@6.12.2", "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/highlight": "^1.0.0", "style-mod": "^4.1.0" } }, "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w=="], - "@codemirror/search": ["@codemirror/search@6.7.1", "https://registry.npmmirror.com/@codemirror/search/-/search-6.7.1.tgz", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA=="], + "@codemirror/search": ["@codemirror/search@6.7.1", "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA=="], - "@codemirror/state": ["@codemirror/state@6.7.1", "https://registry.npmmirror.com/@codemirror/state/-/state-6.7.1.tgz", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A=="], + "@codemirror/state": ["@codemirror/state@6.7.1", "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A=="], - "@codemirror/view": ["@codemirror/view@6.43.7", "https://registry.npmmirror.com/@codemirror/view/-/view-6.43.7.tgz", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g=="], + "@codemirror/view": ["@codemirror/view@6.43.7", "https://registry.npmjs.org/@codemirror/view/-/view-6.43.7.tgz", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g=="], - "@date-fns/tz": ["@date-fns/tz@1.5.0", "https://registry.npmmirror.com/@date-fns/tz/-/tz-1.5.0.tgz", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="], + "@date-fns/tz": ["@date-fns/tz@1.5.0", "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="], - "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], - "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], - "@dnd-kit/modifiers": ["@dnd-kit/modifiers@9.0.0", "https://registry.npmmirror.com/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="], + "@dnd-kit/modifiers": ["@dnd-kit/modifiers@9.0.0", "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="], - "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], - "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], - "@emnapi/core": ["@emnapi/core@1.10.0", "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.10.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.8", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.8.tgz", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.8", "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.8", "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", { "os": "android", "cpu": "arm64" }, "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.8", "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", { "os": "android", "cpu": "arm64" }, "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.8", "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.8.tgz", { "os": "android", "cpu": "x64" }, "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.8", "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", { "os": "android", "cpu": "x64" }, "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.8", "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.8", "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.8", "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.8", "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.8", "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.8", "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.8", "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.8", "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.8", "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", { "os": "linux", "cpu": "arm" }, "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.8", "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", { "os": "linux", "cpu": "arm" }, "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.8", "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.8", "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.8", "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", { "os": "linux", "cpu": "ia32" }, "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.8", "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", { "os": "linux", "cpu": "ia32" }, "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.8", "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", { "os": "linux", "cpu": "none" }, "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.8", "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", { "os": "linux", "cpu": "none" }, "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.8", "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", { "os": "linux", "cpu": "none" }, "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.8", "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", { "os": "linux", "cpu": "none" }, "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.8", "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.8", "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.8", "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", { "os": "linux", "cpu": "none" }, "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.8", "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", { "os": "linux", "cpu": "none" }, "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.8", "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.8", "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.8", "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", { "os": "linux", "cpu": "x64" }, "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.8", "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", { "os": "linux", "cpu": "x64" }, "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.8", "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", { "os": "none", "cpu": "arm64" }, "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.8", "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", { "os": "none", "cpu": "arm64" }, "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.8", "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", { "os": "none", "cpu": "x64" }, "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.8", "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", { "os": "none", "cpu": "x64" }, "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.8", "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.8", "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.8", "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.8", "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.8", "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", { "os": "none", "cpu": "arm64" }, "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.8", "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", { "os": "none", "cpu": "arm64" }, "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.8", "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.8", "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.8", "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.8", "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.8", "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.8", "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="], - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - "@eslint/config-array": ["@eslint/config-array@0.23.5", "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.23.5.tgz", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], + "@eslint/config-array": ["@eslint/config-array@0.23.5", "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], - "@eslint/core": ["@eslint/core@1.2.1", "https://registry.npmmirror.com/@eslint/core/-/core-1.2.1.tgz", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + "@eslint/core": ["@eslint/core@1.2.1", "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], - "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-3.0.5.tgz", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], - "@floating-ui/core": ["@floating-ui/core@1.7.5", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], - "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], - "@floating-ui/react": ["@floating-ui/react@0.27.20", "https://registry.npmmirror.com/@floating-ui/react/-/react-0.27.20.tgz", { "dependencies": { "@floating-ui/react-dom": "^2.1.9", "@floating-ui/utils": "^0.2.12", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw=="], + "@floating-ui/react": ["@floating-ui/react@0.27.20", "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz", { "dependencies": { "@floating-ui/react-dom": "^2.1.9", "@floating-ui/utils": "^0.2.12", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw=="], - "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], - "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - "@fontsource/geist-mono": ["@fontsource/geist-mono@5.2.8", "https://registry.npmmirror.com/@fontsource/geist-mono/-/geist-mono-5.2.8.tgz", {}, "sha512-YqZUb3X42GjRaHkibUNyE8K4Rk9A6I9Ez81YpJYiuJN4ggmTW2ixoQpa9EWCPfXZtIsCQJTyrDoV9OJOZO/UcA=="], + "@fontsource/geist-mono": ["@fontsource/geist-mono@5.2.8", "https://registry.npmjs.org/@fontsource/geist-mono/-/geist-mono-5.2.8.tgz", {}, "sha512-YqZUb3X42GjRaHkibUNyE8K4Rk9A6I9Ez81YpJYiuJN4ggmTW2ixoQpa9EWCPfXZtIsCQJTyrDoV9OJOZO/UcA=="], - "@fontsource/geist-sans": ["@fontsource/geist-sans@5.2.5", "https://registry.npmmirror.com/@fontsource/geist-sans/-/geist-sans-5.2.5.tgz", {}, "sha512-anllOHyJbElRs9fV15TeDRqAeb1IKm4bSknPl6ZMoyPTx1BBy7logudcUwpNjmQLkzn4Q0JGQLRCUKJYoyST6A=="], + "@fontsource/geist-sans": ["@fontsource/geist-sans@5.2.5", "https://registry.npmjs.org/@fontsource/geist-sans/-/geist-sans-5.2.5.tgz", {}, "sha512-anllOHyJbElRs9fV15TeDRqAeb1IKm4bSknPl6ZMoyPTx1BBy7logudcUwpNjmQLkzn4Q0JGQLRCUKJYoyST6A=="], - "@fontsource/jetbrains-mono": ["@fontsource/jetbrains-mono@5.3.0", "https://registry.npmmirror.com/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", {}, "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A=="], + "@fontsource/jetbrains-mono": ["@fontsource/jetbrains-mono@5.3.0", "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", {}, "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A=="], - "@humanfs/core": ["@humanfs/core@0.19.2", "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.2.tgz", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + "@humanfs/core": ["@humanfs/core@0.19.2", "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], - "@humanfs/node": ["@humanfs/node@0.16.8", "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.8.tgz", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + "@humanfs/node": ["@humanfs/node@0.16.8", "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], - "@humanfs/types": ["@humanfs/types@0.15.0", "https://registry.npmmirror.com/@humanfs/types/-/types-0.15.0.tgz", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + "@humanfs/types": ["@humanfs/types@0.15.0", "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], - "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], - "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - "@iarna/toml": ["@iarna/toml@2.2.5", "https://registry.npmmirror.com/@iarna/toml/-/toml-2.2.5.tgz", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="], + "@iarna/toml": ["@iarna/toml@2.2.5", "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="], - "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], - "@lexical/a11y": ["@lexical/a11y@0.48.0", "https://registry.npmmirror.com/@lexical/a11y/-/a11y-0.48.0.tgz", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-18W4ehyipkUim4YVoDZitoH63Om3j6iCN4c84zdqE9RgkWf/PE4rvI/8BHTm6Ni7NkVE14nimXgkpaP5ok15zA=="], + "@lexical/a11y": ["@lexical/a11y@0.48.0", "https://registry.npmjs.org/@lexical/a11y/-/a11y-0.48.0.tgz", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-18W4ehyipkUim4YVoDZitoH63Om3j6iCN4c84zdqE9RgkWf/PE4rvI/8BHTm6Ni7NkVE14nimXgkpaP5ok15zA=="], - "@lexical/clipboard": ["@lexical/clipboard@0.48.0", "https://registry.npmmirror.com/@lexical/clipboard/-/clipboard-0.48.0.tgz", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/html": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/list": "0.48.0", "@lexical/selection": "0.48.0", "@lexical/utils": "0.48.0", "@types/trusted-types": "^2.0.7", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-xO2trk6+yBl8XXa/VNe20kXczmPxFoWtUHjidbBLEtlGBj+mo63pJj6H5o/WlZsoMKmIOJxwxsn2ejrS8G0/7A=="], + "@lexical/clipboard": ["@lexical/clipboard@0.48.0", "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.48.0.tgz", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/html": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/list": "0.48.0", "@lexical/selection": "0.48.0", "@lexical/utils": "0.48.0", "@types/trusted-types": "^2.0.7", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-xO2trk6+yBl8XXa/VNe20kXczmPxFoWtUHjidbBLEtlGBj+mo63pJj6H5o/WlZsoMKmIOJxwxsn2ejrS8G0/7A=="], - "@lexical/code-core": ["@lexical/code-core@0.48.0", "https://registry.npmmirror.com/@lexical/code-core/-/code-core-0.48.0.tgz", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/html": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-+O1Ge06AuSo6+r8R2Xk6SkWG07H5/e4K/Scw9aqCM/BRjITxgvFTHTNvgQbaobCsP0uBJiwW1+ZGeWAWcBCY4w=="], + "@lexical/code-core": ["@lexical/code-core@0.48.0", "https://registry.npmjs.org/@lexical/code-core/-/code-core-0.48.0.tgz", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/html": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-+O1Ge06AuSo6+r8R2Xk6SkWG07H5/e4K/Scw9aqCM/BRjITxgvFTHTNvgQbaobCsP0uBJiwW1+ZGeWAWcBCY4w=="], - "@lexical/devtools-core": ["@lexical/devtools-core@0.48.0", "https://registry.npmmirror.com/@lexical/devtools-core/-/devtools-core-0.48.0.tgz", { "dependencies": { "@lexical/html": "0.48.0", "@lexical/link": "0.48.0", "@lexical/mark": "0.48.0", "@lexical/table": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "react": ">=18.x", "react-dom": ">=18.x", "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-4kvKWW6ebgQnJNLXPLmw7dqgSChvzYIBNYtfuR6c48Sw+V/QXQTWqfIUbCIe5X4uG8EEXd5O/udXaJx7GBuP+w=="], + "@lexical/devtools-core": ["@lexical/devtools-core@0.48.0", "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.48.0.tgz", { "dependencies": { "@lexical/html": "0.48.0", "@lexical/link": "0.48.0", "@lexical/mark": "0.48.0", "@lexical/table": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "react": ">=18.x", "react-dom": ">=18.x", "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-4kvKWW6ebgQnJNLXPLmw7dqgSChvzYIBNYtfuR6c48Sw+V/QXQTWqfIUbCIe5X4uG8EEXd5O/udXaJx7GBuP+w=="], - "@lexical/dragon": ["@lexical/dragon@0.48.0", "https://registry.npmmirror.com/@lexical/dragon/-/dragon-0.48.0.tgz", { "dependencies": { "@lexical/extension": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-uPuu7fVca9vmL/Oz30CRZ7FIPIodwMrTgNsRmV8jE6Qd6a7RNiTW7r3+EhbhIdkLb/sjcWsYyOMyUR1TJAB0wQ=="], + "@lexical/dragon": ["@lexical/dragon@0.48.0", "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.48.0.tgz", { "dependencies": { "@lexical/extension": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-uPuu7fVca9vmL/Oz30CRZ7FIPIodwMrTgNsRmV8jE6Qd6a7RNiTW7r3+EhbhIdkLb/sjcWsYyOMyUR1TJAB0wQ=="], - "@lexical/extension": ["@lexical/extension@0.48.0", "https://registry.npmmirror.com/@lexical/extension/-/extension-0.48.0.tgz", { "dependencies": { "@lexical/internal": "0.48.0", "@lexical/utils": "0.48.0", "@preact/signals-core": "^1.14.1", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-4uBObgz84mVbQWiumndmIhkuJL0ojHiMwFSvSUM/FCo1YMVIZvpI56blI0y+2Vix/oLui9EgVQSJjjWV4NAszw=="], + "@lexical/extension": ["@lexical/extension@0.48.0", "https://registry.npmjs.org/@lexical/extension/-/extension-0.48.0.tgz", { "dependencies": { "@lexical/internal": "0.48.0", "@lexical/utils": "0.48.0", "@preact/signals-core": "^1.14.1", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-4uBObgz84mVbQWiumndmIhkuJL0ojHiMwFSvSUM/FCo1YMVIZvpI56blI0y+2Vix/oLui9EgVQSJjjWV4NAszw=="], - "@lexical/hashtag": ["@lexical/hashtag@0.48.0", "https://registry.npmmirror.com/@lexical/hashtag/-/hashtag-0.48.0.tgz", { "dependencies": { "@lexical/text": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-hPQtdnbVoNAFsmfnCGfgY7mDbvk6mIznlCRmIR7tLeQKXqz/0Tb6eH3W24EESk9hAF8wFUYNKWE1/Kb3Hl2vEQ=="], + "@lexical/hashtag": ["@lexical/hashtag@0.48.0", "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.48.0.tgz", { "dependencies": { "@lexical/text": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-hPQtdnbVoNAFsmfnCGfgY7mDbvk6mIznlCRmIR7tLeQKXqz/0Tb6eH3W24EESk9hAF8wFUYNKWE1/Kb3Hl2vEQ=="], - "@lexical/history": ["@lexical/history@0.48.0", "https://registry.npmmirror.com/@lexical/history/-/history-0.48.0.tgz", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-NllvUfO+u3mfi5uC8k2CodwdzeeopFFVtMZ/NMifzFbZFysdWi9m9mqfO46NrEA1rSFOydyefv6oMzC8ULXInA=="], + "@lexical/history": ["@lexical/history@0.48.0", "https://registry.npmjs.org/@lexical/history/-/history-0.48.0.tgz", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-NllvUfO+u3mfi5uC8k2CodwdzeeopFFVtMZ/NMifzFbZFysdWi9m9mqfO46NrEA1rSFOydyefv6oMzC8ULXInA=="], - "@lexical/html": ["@lexical/html@0.48.0", "https://registry.npmmirror.com/@lexical/html/-/html-0.48.0.tgz", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/selection": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-uBxlgKl4YgSNEgHJSshdBqtGDzruWdx1ewop+u6faT67qHUdP3P0cUXIrG6NToDWvsL6fzCstAbN76PMER1Pnw=="], + "@lexical/html": ["@lexical/html@0.48.0", "https://registry.npmjs.org/@lexical/html/-/html-0.48.0.tgz", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/selection": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-uBxlgKl4YgSNEgHJSshdBqtGDzruWdx1ewop+u6faT67qHUdP3P0cUXIrG6NToDWvsL6fzCstAbN76PMER1Pnw=="], - "@lexical/internal": ["@lexical/internal@0.48.0", "https://registry.npmmirror.com/@lexical/internal/-/internal-0.48.0.tgz", { "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-sRwg53K7N0ZQ7KNAvcCY38LSwGizbXP1zlR1lIojZp0GoqHWNvR+vL49t1wYXu1nXx3Osf4ilHHm+aGcwq5hTw=="], + "@lexical/internal": ["@lexical/internal@0.48.0", "https://registry.npmjs.org/@lexical/internal/-/internal-0.48.0.tgz", { "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-sRwg53K7N0ZQ7KNAvcCY38LSwGizbXP1zlR1lIojZp0GoqHWNvR+vL49t1wYXu1nXx3Osf4ilHHm+aGcwq5hTw=="], - "@lexical/link": ["@lexical/link@0.48.0", "https://registry.npmmirror.com/@lexical/link/-/link-0.48.0.tgz", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/html": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-E0UDmNLUXs/yMCnnE7hbFO0CvhWghmqa+qqPksFfzLkpMHdPpdS1yg59YEbYoNHLi/DXIu4cFRvpHIEuooxwNg=="], + "@lexical/link": ["@lexical/link@0.48.0", "https://registry.npmjs.org/@lexical/link/-/link-0.48.0.tgz", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/html": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-E0UDmNLUXs/yMCnnE7hbFO0CvhWghmqa+qqPksFfzLkpMHdPpdS1yg59YEbYoNHLi/DXIu4cFRvpHIEuooxwNg=="], - "@lexical/list": ["@lexical/list@0.48.0", "https://registry.npmmirror.com/@lexical/list/-/list-0.48.0.tgz", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/html": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-9Qe/Vur44v9F9enj55SUzf79FVsijcGOQug7SpiIU8ekLr7JNzcilKYBYcZ6etGEo7bqQHsYMHXeJcSBbCI2zA=="], + "@lexical/list": ["@lexical/list@0.48.0", "https://registry.npmjs.org/@lexical/list/-/list-0.48.0.tgz", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/html": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-9Qe/Vur44v9F9enj55SUzf79FVsijcGOQug7SpiIU8ekLr7JNzcilKYBYcZ6etGEo7bqQHsYMHXeJcSBbCI2zA=="], - "@lexical/mark": ["@lexical/mark@0.48.0", "https://registry.npmmirror.com/@lexical/mark/-/mark-0.48.0.tgz", { "dependencies": { "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-DTtypWvnYSXyNUxEmsUnh4y0xXkmdk8Y72EZl8WDHcCwjqaLJlUakH7p/TuSJczs3uVSaoJzu0yh7oGkP1Vsvw=="], + "@lexical/mark": ["@lexical/mark@0.48.0", "https://registry.npmjs.org/@lexical/mark/-/mark-0.48.0.tgz", { "dependencies": { "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-DTtypWvnYSXyNUxEmsUnh4y0xXkmdk8Y72EZl8WDHcCwjqaLJlUakH7p/TuSJczs3uVSaoJzu0yh7oGkP1Vsvw=="], - "@lexical/markdown": ["@lexical/markdown@0.48.0", "https://registry.npmmirror.com/@lexical/markdown/-/markdown-0.48.0.tgz", { "dependencies": { "@lexical/code-core": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/link": "0.48.0", "@lexical/list": "0.48.0", "@lexical/rich-text": "0.48.0", "@lexical/selection": "0.48.0", "@lexical/text": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-1WasBenW4bEsa5xtnycVo8G2hcxIqyYLn3/r98yD2Y+54ZyeFuIQfOeJYhIgCh4YkKpfqyrFwkMQwAOsQDqZjA=="], + "@lexical/markdown": ["@lexical/markdown@0.48.0", "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.48.0.tgz", { "dependencies": { "@lexical/code-core": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/link": "0.48.0", "@lexical/list": "0.48.0", "@lexical/rich-text": "0.48.0", "@lexical/selection": "0.48.0", "@lexical/text": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-1WasBenW4bEsa5xtnycVo8G2hcxIqyYLn3/r98yD2Y+54ZyeFuIQfOeJYhIgCh4YkKpfqyrFwkMQwAOsQDqZjA=="], - "@lexical/overflow": ["@lexical/overflow@0.48.0", "https://registry.npmmirror.com/@lexical/overflow/-/overflow-0.48.0.tgz", { "dependencies": { "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-1YEvMz2tW3EbwrON9mjrkjMVl/vdTcPYSn9P1j6mf5gj0LOoLDNI4TbvSD4SViy+TDghxNdG8YdCISyU2b4YKA=="], + "@lexical/overflow": ["@lexical/overflow@0.48.0", "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.48.0.tgz", { "dependencies": { "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-1YEvMz2tW3EbwrON9mjrkjMVl/vdTcPYSn9P1j6mf5gj0LOoLDNI4TbvSD4SViy+TDghxNdG8YdCISyU2b4YKA=="], - "@lexical/plain-text": ["@lexical/plain-text@0.48.0", "https://registry.npmmirror.com/@lexical/plain-text/-/plain-text-0.48.0.tgz", { "dependencies": { "@lexical/clipboard": "0.48.0", "@lexical/dragon": "0.48.0", "@lexical/extension": "0.48.0", "@lexical/selection": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-q4f/4VZKVgCrIW2FhDFR2RII1BU0ljedPgEmJ8XQn1zc+JOFPom8Lp0lV5nyEvZaAJYwM/TfoJ9g2V7ESFKznA=="], + "@lexical/plain-text": ["@lexical/plain-text@0.48.0", "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.48.0.tgz", { "dependencies": { "@lexical/clipboard": "0.48.0", "@lexical/dragon": "0.48.0", "@lexical/extension": "0.48.0", "@lexical/selection": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-q4f/4VZKVgCrIW2FhDFR2RII1BU0ljedPgEmJ8XQn1zc+JOFPom8Lp0lV5nyEvZaAJYwM/TfoJ9g2V7ESFKznA=="], - "@lexical/react": ["@lexical/react@0.48.0", "https://registry.npmmirror.com/@lexical/react/-/react-0.48.0.tgz", { "dependencies": { "@floating-ui/react": "^0.27.19", "@lexical/a11y": "0.48.0", "@lexical/devtools-core": "0.48.0", "@lexical/dragon": "0.48.0", "@lexical/extension": "0.48.0", "@lexical/hashtag": "0.48.0", "@lexical/history": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/link": "0.48.0", "@lexical/list": "0.48.0", "@lexical/mark": "0.48.0", "@lexical/markdown": "0.48.0", "@lexical/overflow": "0.48.0", "@lexical/plain-text": "0.48.0", "@lexical/rich-text": "0.48.0", "@lexical/table": "0.48.0", "@lexical/text": "0.48.0", "@lexical/utils": "0.48.0", "@lexical/yjs": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "react": ">=18.x", "react-dom": ">=18.x", "typescript": ">=5.2", "yjs": ">=13.5.22" }, "optionalPeers": ["typescript", "yjs"] }, "sha512-uVh9/QSrbtjLjVbxfJ+sfiMyhUq/rv7H6uBEVDDIw1rkZJSDY1fvf/CX+dyKgwcDFjKQZ8/9i5f9UCVPeQ01hA=="], + "@lexical/react": ["@lexical/react@0.48.0", "https://registry.npmjs.org/@lexical/react/-/react-0.48.0.tgz", { "dependencies": { "@floating-ui/react": "^0.27.19", "@lexical/a11y": "0.48.0", "@lexical/devtools-core": "0.48.0", "@lexical/dragon": "0.48.0", "@lexical/extension": "0.48.0", "@lexical/hashtag": "0.48.0", "@lexical/history": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/link": "0.48.0", "@lexical/list": "0.48.0", "@lexical/mark": "0.48.0", "@lexical/markdown": "0.48.0", "@lexical/overflow": "0.48.0", "@lexical/plain-text": "0.48.0", "@lexical/rich-text": "0.48.0", "@lexical/table": "0.48.0", "@lexical/text": "0.48.0", "@lexical/utils": "0.48.0", "@lexical/yjs": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "react": ">=18.x", "react-dom": ">=18.x", "typescript": ">=5.2", "yjs": ">=13.5.22" }, "optionalPeers": ["typescript", "yjs"] }, "sha512-uVh9/QSrbtjLjVbxfJ+sfiMyhUq/rv7H6uBEVDDIw1rkZJSDY1fvf/CX+dyKgwcDFjKQZ8/9i5f9UCVPeQ01hA=="], - "@lexical/rich-text": ["@lexical/rich-text@0.48.0", "https://registry.npmmirror.com/@lexical/rich-text/-/rich-text-0.48.0.tgz", { "dependencies": { "@lexical/clipboard": "0.48.0", "@lexical/dragon": "0.48.0", "@lexical/extension": "0.48.0", "@lexical/html": "0.48.0", "@lexical/selection": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-QMXFnwCKAQ4yzxvx5FwmANcx3K+NaBkGTxAVD8s8pOKDD/U5rzDS1iIvhH6TLWaFp7VzvLmLB+Sl1Ie/RnkaDQ=="], + "@lexical/rich-text": ["@lexical/rich-text@0.48.0", "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.48.0.tgz", { "dependencies": { "@lexical/clipboard": "0.48.0", "@lexical/dragon": "0.48.0", "@lexical/extension": "0.48.0", "@lexical/html": "0.48.0", "@lexical/selection": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-QMXFnwCKAQ4yzxvx5FwmANcx3K+NaBkGTxAVD8s8pOKDD/U5rzDS1iIvhH6TLWaFp7VzvLmLB+Sl1Ie/RnkaDQ=="], - "@lexical/selection": ["@lexical/selection@0.48.0", "https://registry.npmmirror.com/@lexical/selection/-/selection-0.48.0.tgz", { "dependencies": { "@lexical/internal": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-Uc0wTrEtHcYK6z/aHHjkgH3vX/R4Bf8mO+qH3VbxfSAKYzNYktM0j+ZGdq5kIEL4frnw/9SulNCXlH7xpjuDgA=="], + "@lexical/selection": ["@lexical/selection@0.48.0", "https://registry.npmjs.org/@lexical/selection/-/selection-0.48.0.tgz", { "dependencies": { "@lexical/internal": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-Uc0wTrEtHcYK6z/aHHjkgH3vX/R4Bf8mO+qH3VbxfSAKYzNYktM0j+ZGdq5kIEL4frnw/9SulNCXlH7xpjuDgA=="], - "@lexical/table": ["@lexical/table@0.48.0", "https://registry.npmmirror.com/@lexical/table/-/table-0.48.0.tgz", { "dependencies": { "@lexical/clipboard": "0.48.0", "@lexical/extension": "0.48.0", "@lexical/html": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-t9Mz7q6ODLUz0lG5Xn9EY/5YiVpTHCqlPQP4EtFXlnBQT3DuKeDS3cC0Cn8sGSZc11YY5OLDfWpB64Frs9BL3g=="], + "@lexical/table": ["@lexical/table@0.48.0", "https://registry.npmjs.org/@lexical/table/-/table-0.48.0.tgz", { "dependencies": { "@lexical/clipboard": "0.48.0", "@lexical/extension": "0.48.0", "@lexical/html": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-t9Mz7q6ODLUz0lG5Xn9EY/5YiVpTHCqlPQP4EtFXlnBQT3DuKeDS3cC0Cn8sGSZc11YY5OLDfWpB64Frs9BL3g=="], - "@lexical/text": ["@lexical/text@0.48.0", "https://registry.npmmirror.com/@lexical/text/-/text-0.48.0.tgz", { "dependencies": { "@lexical/internal": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-ktTMRbsX4wKxdG2OpZCkrqtt8k9Vg/ZpWdukOQ0r1xPRtCuL1T+q91l7cy2ywIuCfMGYS0aoZGB4LdpUMe/H1g=="], + "@lexical/text": ["@lexical/text@0.48.0", "https://registry.npmjs.org/@lexical/text/-/text-0.48.0.tgz", { "dependencies": { "@lexical/internal": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-ktTMRbsX4wKxdG2OpZCkrqtt8k9Vg/ZpWdukOQ0r1xPRtCuL1T+q91l7cy2ywIuCfMGYS0aoZGB4LdpUMe/H1g=="], - "@lexical/utils": ["@lexical/utils@0.48.0", "https://registry.npmmirror.com/@lexical/utils/-/utils-0.48.0.tgz", { "dependencies": { "@lexical/internal": "0.48.0", "@lexical/selection": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-W4k4P+y6jmRfna8+ad4X+iMd5h8es5PC3bUw5tbi7MRApxaaFG/0w+uJiZVSwbT2Q6JnA2xhBaqzPgt/Gn6djg=="], + "@lexical/utils": ["@lexical/utils@0.48.0", "https://registry.npmjs.org/@lexical/utils/-/utils-0.48.0.tgz", { "dependencies": { "@lexical/internal": "0.48.0", "@lexical/selection": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-W4k4P+y6jmRfna8+ad4X+iMd5h8es5PC3bUw5tbi7MRApxaaFG/0w+uJiZVSwbT2Q6JnA2xhBaqzPgt/Gn6djg=="], - "@lexical/yjs": ["@lexical/yjs@0.48.0", "https://registry.npmmirror.com/@lexical/yjs/-/yjs-0.48.0.tgz", { "dependencies": { "@lexical/internal": "0.48.0", "@lexical/selection": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2", "yjs": ">=13.5.22" }, "optionalPeers": ["typescript"] }, "sha512-fFsE8EnPM/2KK9rMJ0z6T+Da5UW5V4P+XiAA03LoHTY5YQ/Oy8Q0i7Wcmocv/B/SpsY2o8g07euZEfudl9MVKA=="], + "@lexical/yjs": ["@lexical/yjs@0.48.0", "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.48.0.tgz", { "dependencies": { "@lexical/internal": "0.48.0", "@lexical/selection": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2", "yjs": ">=13.5.22" }, "optionalPeers": ["typescript"] }, "sha512-fFsE8EnPM/2KK9rMJ0z6T+Da5UW5V4P+XiAA03LoHTY5YQ/Oy8Q0i7Wcmocv/B/SpsY2o8g07euZEfudl9MVKA=="], - "@lezer/common": ["@lezer/common@1.5.2", "https://registry.npmmirror.com/@lezer/common/-/common-1.5.2.tgz", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="], + "@lezer/common": ["@lezer/common@1.5.2", "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="], - "@lezer/cpp": ["@lezer/cpp@1.1.6", "https://registry.npmmirror.com/@lezer/cpp/-/cpp-1.1.6.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA=="], + "@lezer/cpp": ["@lezer/cpp@1.1.6", "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.6.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA=="], - "@lezer/css": ["@lezer/css@1.3.4", "https://registry.npmmirror.com/@lezer/css/-/css-1.3.4.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q=="], + "@lezer/css": ["@lezer/css@1.3.4", "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q=="], - "@lezer/go": ["@lezer/go@1.0.1", "https://registry.npmmirror.com/@lezer/go/-/go-1.0.1.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ=="], + "@lezer/go": ["@lezer/go@1.0.1", "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ=="], - "@lezer/highlight": ["@lezer/highlight@1.2.3", "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], + "@lezer/highlight": ["@lezer/highlight@1.2.3", "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], - "@lezer/html": ["@lezer/html@1.3.13", "https://registry.npmmirror.com/@lezer/html/-/html-1.3.13.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="], + "@lezer/html": ["@lezer/html@1.3.13", "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="], - "@lezer/java": ["@lezer/java@1.1.3", "https://registry.npmmirror.com/@lezer/java/-/java-1.1.3.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw=="], + "@lezer/java": ["@lezer/java@1.1.3", "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw=="], - "@lezer/javascript": ["@lezer/javascript@1.5.4", "https://registry.npmmirror.com/@lezer/javascript/-/javascript-1.5.4.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="], + "@lezer/javascript": ["@lezer/javascript@1.5.4", "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="], - "@lezer/json": ["@lezer/json@1.0.3", "https://registry.npmmirror.com/@lezer/json/-/json-1.0.3.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="], + "@lezer/json": ["@lezer/json@1.0.3", "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="], - "@lezer/lr": ["@lezer/lr@1.4.10", "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.10.tgz", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="], + "@lezer/lr": ["@lezer/lr@1.4.10", "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="], - "@lezer/markdown": ["@lezer/markdown@1.7.2", "https://registry.npmmirror.com/@lezer/markdown/-/markdown-1.7.2.tgz", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ=="], + "@lezer/markdown": ["@lezer/markdown@1.7.2", "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ=="], - "@lezer/php": ["@lezer/php@1.0.5", "https://registry.npmmirror.com/@lezer/php/-/php-1.0.5.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.1.0" } }, "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA=="], + "@lezer/php": ["@lezer/php@1.0.5", "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.1.0" } }, "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA=="], - "@lezer/python": ["@lezer/python@1.1.19", "https://registry.npmmirror.com/@lezer/python/-/python-1.1.19.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ=="], + "@lezer/python": ["@lezer/python@1.1.19", "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ=="], - "@lezer/rust": ["@lezer/rust@1.0.2", "https://registry.npmmirror.com/@lezer/rust/-/rust-1.0.2.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg=="], + "@lezer/rust": ["@lezer/rust@1.0.2", "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg=="], - "@lezer/sass": ["@lezer/sass@1.1.0", "https://registry.npmmirror.com/@lezer/sass/-/sass-1.1.0.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ=="], + "@lezer/sass": ["@lezer/sass@1.1.0", "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ=="], - "@lezer/xml": ["@lezer/xml@1.0.6", "https://registry.npmmirror.com/@lezer/xml/-/xml-1.0.6.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww=="], + "@lezer/xml": ["@lezer/xml@1.0.6", "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww=="], - "@lezer/yaml": ["@lezer/yaml@1.0.4", "https://registry.npmmirror.com/@lezer/yaml/-/yaml-1.0.4.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.4.0" } }, "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw=="], + "@lezer/yaml": ["@lezer/yaml@1.0.4", "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.4.0" } }, "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw=="], "@lithe/editor": ["@lithe/editor@workspace:frontend/editor"], - "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.3", "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", {}, "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA=="], + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.3", "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", {}, "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA=="], - "@mdxeditor/editor": ["@mdxeditor/editor@4.1.1", "https://registry.npmmirror.com/@mdxeditor/editor/-/editor-4.1.1.tgz", { "dependencies": { "@codemirror/commands": "^6.2.4", "@codemirror/lang-markdown": "^6.2.3", "@codemirror/language-data": "^6.5.1", "@codemirror/merge": "^6.4.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.23.0", "@lexical/clipboard": "^0.48.0", "@lexical/extension": "^0.48.0", "@lexical/history": "^0.48.0", "@lexical/link": "^0.48.0", "@lexical/list": "^0.48.0", "@lexical/markdown": "^0.48.0", "@lexical/plain-text": "^0.48.0", "@lexical/react": "^0.48.0", "@lexical/rich-text": "^0.48.0", "@lexical/selection": "^0.48.0", "@lexical/utils": "^0.48.0", "@mdxeditor/gurx": "^1.2.4", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-dialog": "^1.1.11", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-popover": "^1.1.11", "@radix-ui/react-popper": "^1.2.4", "@radix-ui/react-select": "^2.2.2", "@radix-ui/react-toggle-group": "^1.1.7", "@radix-ui/react-toolbar": "^1.1.7", "@radix-ui/react-tooltip": "^1.2.4", "classnames": "^2.3.2", "cm6-theme-basic-light": "^0.2.0", "codemirror": "^6.0.1", "downshift": "^7.6.0", "js-yaml": "4.3.0", "lexical": "^0.48.0", "mdast-util-directive": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-frontmatter": "^2.0.1", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-highlight-mark": "^1.2.2", "mdast-util-mdx": "^3.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-to-markdown": "^2.1.0", "micromark-extension-directive": "^3.0.0", "micromark-extension-frontmatter": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.1", "micromark-extension-highlight-mark": "^1.2.0", "micromark-extension-mdx-jsx": "^3.0.0", "micromark-extension-mdx-md": "^2.0.0", "micromark-extension-mdxjs": "^3.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.1", "micromark-util-symbol": "^2.0.0", "react-hook-form": "^7.56.1", "unidiff": "^1.0.2" }, "peerDependencies": { "react": ">= 18 || >= 19", "react-dom": ">= 18 || >= 19" } }, "sha512-rKv28Qlv0hB+SA5tmbUdwTY9tztjD3oOBz3aGvJMM67moJhpS0J/NoGvgylaRY3T5ydaL97W3ouZlsKdMRSDow=="], + "@mdxeditor/editor": ["@mdxeditor/editor@4.1.1", "https://registry.npmjs.org/@mdxeditor/editor/-/editor-4.1.1.tgz", { "dependencies": { "@codemirror/commands": "^6.2.4", "@codemirror/lang-markdown": "^6.2.3", "@codemirror/language-data": "^6.5.1", "@codemirror/merge": "^6.4.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.23.0", "@lexical/clipboard": "^0.48.0", "@lexical/extension": "^0.48.0", "@lexical/history": "^0.48.0", "@lexical/link": "^0.48.0", "@lexical/list": "^0.48.0", "@lexical/markdown": "^0.48.0", "@lexical/plain-text": "^0.48.0", "@lexical/react": "^0.48.0", "@lexical/rich-text": "^0.48.0", "@lexical/selection": "^0.48.0", "@lexical/utils": "^0.48.0", "@mdxeditor/gurx": "^1.2.4", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-dialog": "^1.1.11", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-popover": "^1.1.11", "@radix-ui/react-popper": "^1.2.4", "@radix-ui/react-select": "^2.2.2", "@radix-ui/react-toggle-group": "^1.1.7", "@radix-ui/react-toolbar": "^1.1.7", "@radix-ui/react-tooltip": "^1.2.4", "classnames": "^2.3.2", "cm6-theme-basic-light": "^0.2.0", "codemirror": "^6.0.1", "downshift": "^7.6.0", "js-yaml": "4.3.0", "lexical": "^0.48.0", "mdast-util-directive": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-frontmatter": "^2.0.1", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-highlight-mark": "^1.2.2", "mdast-util-mdx": "^3.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-to-markdown": "^2.1.0", "micromark-extension-directive": "^3.0.0", "micromark-extension-frontmatter": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.1", "micromark-extension-highlight-mark": "^1.2.0", "micromark-extension-mdx-jsx": "^3.0.0", "micromark-extension-mdx-md": "^2.0.0", "micromark-extension-mdxjs": "^3.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.1", "micromark-util-symbol": "^2.0.0", "react-hook-form": "^7.56.1", "unidiff": "^1.0.2" }, "peerDependencies": { "react": ">= 18 || >= 19", "react-dom": ">= 18 || >= 19" } }, "sha512-rKv28Qlv0hB+SA5tmbUdwTY9tztjD3oOBz3aGvJMM67moJhpS0J/NoGvgylaRY3T5ydaL97W3ouZlsKdMRSDow=="], - "@mdxeditor/gurx": ["@mdxeditor/gurx@1.2.4", "https://registry.npmmirror.com/@mdxeditor/gurx/-/gurx-1.2.4.tgz", { "peerDependencies": { "react": ">= 18 || >= 19", "react-dom": ">= 18 || >= 19" } }, "sha512-9ZykIFYhKaXaaSPCs1cuI+FvYDegJjbKwmA4ASE/zY+hJY6EYqvoye4esiO85CjhOw9aoD/izD/CU78/egVqmg=="], + "@mdxeditor/gurx": ["@mdxeditor/gurx@1.2.4", "https://registry.npmjs.org/@mdxeditor/gurx/-/gurx-1.2.4.tgz", { "peerDependencies": { "react": ">= 18 || >= 19", "react-dom": ">= 18 || >= 19" } }, "sha512-9ZykIFYhKaXaaSPCs1cuI+FvYDegJjbKwmA4ASE/zY+hJY6EYqvoye4esiO85CjhOw9aoD/izD/CU78/egVqmg=="], - "@napi-rs/canvas": ["@napi-rs/canvas@1.0.0", "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-1.0.0.tgz", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.0", "@napi-rs/canvas-darwin-arm64": "1.0.0", "@napi-rs/canvas-darwin-x64": "1.0.0", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.0", "@napi-rs/canvas-linux-arm64-gnu": "1.0.0", "@napi-rs/canvas-linux-arm64-musl": "1.0.0", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.0", "@napi-rs/canvas-linux-x64-gnu": "1.0.0", "@napi-rs/canvas-linux-x64-musl": "1.0.0", "@napi-rs/canvas-win32-arm64-msvc": "1.0.0", "@napi-rs/canvas-win32-x64-msvc": "1.0.0" } }, "sha512-Jqxcy1XOIqj+lH9sl1GT+il6GR3uQv13vI2mrwubP3uT8Olak2ClDrK2RnxlQKjwv8BRr4b3ug0YR7c6hBX8wg=="], + "@napi-rs/canvas": ["@napi-rs/canvas@1.0.0", "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.0.tgz", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.0", "@napi-rs/canvas-darwin-arm64": "1.0.0", "@napi-rs/canvas-darwin-x64": "1.0.0", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.0", "@napi-rs/canvas-linux-arm64-gnu": "1.0.0", "@napi-rs/canvas-linux-arm64-musl": "1.0.0", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.0", "@napi-rs/canvas-linux-x64-gnu": "1.0.0", "@napi-rs/canvas-linux-x64-musl": "1.0.0", "@napi-rs/canvas-win32-arm64-msvc": "1.0.0", "@napi-rs/canvas-win32-x64-msvc": "1.0.0" } }, "sha512-Jqxcy1XOIqj+lH9sl1GT+il6GR3uQv13vI2mrwubP3uT8Olak2ClDrK2RnxlQKjwv8BRr4b3ug0YR7c6hBX8wg=="], - "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.0", "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-3hNKJObUK7JsCF9aJlVCs1J0/KE/gGfZNeK8MO1ge6bB3aicr5walGme9t9No1f/oyk9GgvdAT/rjSdsx3gbIw=="], + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.0", "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-3hNKJObUK7JsCF9aJlVCs1J0/KE/gGfZNeK8MO1ge6bB3aicr5walGme9t9No1f/oyk9GgvdAT/rjSdsx3gbIw=="], - "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.0", "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-ZIja19/BiGz2puhki+WUYSRriwFeFJ8Mi9eK3hZdSS85w4Y60cuEAJVhMCfKwswQkKkUtrnzdKMBuO7TupvexA=="], + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.0", "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-ZIja19/BiGz2puhki+WUYSRriwFeFJ8Mi9eK3hZdSS85w4Y60cuEAJVhMCfKwswQkKkUtrnzdKMBuO7TupvexA=="], - "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.0", "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-hImggWc82jqZVpEsFR9S7PE9OQYjq/H/D7vwCGB6X1jRH+UVBP1+1niJTPBOat1B154T6GKK7/kcFtoWgjgFzQ=="], + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.0", "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-hImggWc82jqZVpEsFR9S7PE9OQYjq/H/D7vwCGB6X1jRH+UVBP1+1niJTPBOat1B154T6GKK7/kcFtoWgjgFzQ=="], - "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.0", "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-hlJRy6d+kWLKVOG/+1rEvNQVURZ0DxxRPJsLmEWwhwiXZUJc0BF5o9esALHSEP4CoJK4wChRtj3hnyBgVx2oWA=="], + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.0", "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-hlJRy6d+kWLKVOG/+1rEvNQVURZ0DxxRPJsLmEWwhwiXZUJc0BF5o9esALHSEP4CoJK4wChRtj3hnyBgVx2oWA=="], - "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.0", "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-5Hru4T3RXkosRQafcjelv7AUzw9mXqmGYsxnzeDDOWveFCJyEPMSJltvGCM+jfH98seOCbfwm9KyFg6Jm5FhAA=="], + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.0", "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-5Hru4T3RXkosRQafcjelv7AUzw9mXqmGYsxnzeDDOWveFCJyEPMSJltvGCM+jfH98seOCbfwm9KyFg6Jm5FhAA=="], - "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.0", "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-LTUl9jS8WsLSUGaxQZKQkxfluOJRpgvBuxxdM4pYcjib+di8AU4OzQc6+L6SzGMLcKc9H0RAjojRatBhTMqYdg=="], + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.0", "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-LTUl9jS8WsLSUGaxQZKQkxfluOJRpgvBuxxdM4pYcjib+di8AU4OzQc6+L6SzGMLcKc9H0RAjojRatBhTMqYdg=="], - "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.0", "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-Iz931SAZf+WVDzpjk52Q3ffW3zw0YflFwEZMgs036Wfu1kX/LrwT9wGjsuSqyduqefUkl91/vTdAjn8hQu5ezA=="], + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.0", "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-Iz931SAZf+WVDzpjk52Q3ffW3zw0YflFwEZMgs036Wfu1kX/LrwT9wGjsuSqyduqefUkl91/vTdAjn8hQu5ezA=="], - "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.0", "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-pFEQ5eFK4JusgN1K6KkO9DKP/Hi1WMJOkF8Ch03/khTc4bFbCKkCCsJG4YcOMOW9bI4XbT2/eMAWxhO0xaWgPA=="], + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.0", "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-pFEQ5eFK4JusgN1K6KkO9DKP/Hi1WMJOkF8Ch03/khTc4bFbCKkCCsJG4YcOMOW9bI4XbT2/eMAWxhO0xaWgPA=="], - "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.0", "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-jnvr8NrLHiZ3NCiOKWqDbkI4Ah+QDrqtZ+sddPZBltEb1mQ2coSvCSJYfict+oAwcm0c970oTmVySpjKP/lnaA=="], + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.0", "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-jnvr8NrLHiZ3NCiOKWqDbkI4Ah+QDrqtZ+sddPZBltEb1mQ2coSvCSJYfict+oAwcm0c970oTmVySpjKP/lnaA=="], - "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.0", "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-y2j9/Gfd5joqiqxdP/L1smqjQ+uAx3C4N0EC7bDHrnZEEH8ToM/OC5p3uHvtj4Lq591aHj+ArL01UDLNwT5HgQ=="], + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.0", "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-y2j9/Gfd5joqiqxdP/L1smqjQ+uAx3C4N0EC7bDHrnZEEH8ToM/OC5p3uHvtj4Lq591aHj+ArL01UDLNwT5HgQ=="], - "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.0", "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-qwdhh9N6Gge/hC4pL9S1tQp0iKwhSl/dYjg7+RGp9k26iRGRi5MqqUyKGOXIWli0zOcuy5Y2wIH/jk2ry6i/jA=="], + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.0", "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-qwdhh9N6Gge/hC4pL9S1tQp0iKwhSl/dYjg7+RGp9k26iRGRi5MqqUyKGOXIWli0zOcuy5Y2wIH/jk2ry6i/jA=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], - "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], - "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], - "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "https://registry.npmmirror.com/@opentelemetry/api/-/api-1.9.1.tgz", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], - "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.214.0", "https://registry.npmmirror.com/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA=="], + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.214.0", "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA=="], - "@opentelemetry/core": ["@opentelemetry/core@2.8.0", "https://registry.npmmirror.com/@opentelemetry/core/-/core-2.8.0.tgz", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], + "@opentelemetry/core": ["@opentelemetry/core@2.8.0", "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], - "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.214.0", "https://registry.npmmirror.com/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w=="], + "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.214.0", "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w=="], - "@opentelemetry/resources": ["@opentelemetry/resources@2.8.0", "https://registry.npmmirror.com/@opentelemetry/resources/-/resources-2.8.0.tgz", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg=="], + "@opentelemetry/resources": ["@opentelemetry/resources@2.8.0", "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg=="], - "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.8.0", "https://registry.npmmirror.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ=="], + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.8.0", "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ=="], - "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "https://registry.npmmirror.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.132.0.tgz", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.132.0.tgz", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], - "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.132.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-SThDrSeamB/kG2+NxcJ5/wSLcV6dUqDknrPLqFYQ0ST/55mtBP4M7Q/f3QbubH6aAd11wpzZn/nwbVRSdobOpg=="], + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.132.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-SThDrSeamB/kG2+NxcJ5/wSLcV6dUqDknrPLqFYQ0ST/55mtBP4M7Q/f3QbubH6aAd11wpzZn/nwbVRSdobOpg=="], - "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.132.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Lc0f/TYoKBghE5/2Gsv7bLXk+TJZunx2Tf61X8hG4ARXdc8UYI26dCGccFSd1AyFbK3jfaNXtMnupggDbjPXdQ=="], + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.132.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Lc0f/TYoKBghE5/2Gsv7bLXk+TJZunx2Tf61X8hG4ARXdc8UYI26dCGccFSd1AyFbK3jfaNXtMnupggDbjPXdQ=="], - "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.132.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-RG2eJIpf7C21z9HSSXFw1bTArdpKe7Y4fwcJTwRq1yCSe1vSavaN9GA1sm9KqzemTLAGVktQ+7qBTGp0vQeUZg=="], + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.132.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-RG2eJIpf7C21z9HSSXFw1bTArdpKe7Y4fwcJTwRq1yCSe1vSavaN9GA1sm9KqzemTLAGVktQ+7qBTGp0vQeUZg=="], - "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.132.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-wQIPntPLtJ8NcBpvKPbEv3NqzV6k8eP8tP/jE9Rg8HTg/j7urZGFSsTCPCW5k77Qfw2DM4vRvc9p3I4yq/Shvw=="], + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.132.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-wQIPntPLtJ8NcBpvKPbEv3NqzV6k8eP8tP/jE9Rg8HTg/j7urZGFSsTCPCW5k77Qfw2DM4vRvc9p3I4yq/Shvw=="], - "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.132.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-PixKEpeSe3yxQWqNyOCBALRYc72+Tj7ILDofUl3iXo25cVOzLA6jHUhmOINRtWIPh7dbUie3QNeabwaQpZTw6w=="], + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.132.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-PixKEpeSe3yxQWqNyOCBALRYc72+Tj7ILDofUl3iXo25cVOzLA6jHUhmOINRtWIPh7dbUie3QNeabwaQpZTw6w=="], - "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.132.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-sCR+DzGHlyHKnbA2z9zWjTUhIo8Sy0enJl4RDsBwPmkxYynPatpwOAWe8W5127SlW0boqUWHGtr1NWn5UwIhXQ=="], + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.132.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-sCR+DzGHlyHKnbA2z9zWjTUhIo8Sy0enJl4RDsBwPmkxYynPatpwOAWe8W5127SlW0boqUWHGtr1NWn5UwIhXQ=="], - "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.132.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-sQBix5P2cW+IpzTcCwYxnh9yALrKSIkKJThspBvMGcygSMnbzkSvhN7SfuX1hvBk8y1XEChsdkU3ET0V5DmzUw=="], + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.132.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-sQBix5P2cW+IpzTcCwYxnh9yALrKSIkKJThspBvMGcygSMnbzkSvhN7SfuX1hvBk8y1XEChsdkU3ET0V5DmzUw=="], - "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.132.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-WozHg3Kc//8Sk756HXXgMbEAvqtG+Lzb9JOojwQzIGDtN78Az2dLttkb71akWYUF/8IgYfDSlfKh4Uot8is5Vw=="], + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.132.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-WozHg3Kc//8Sk756HXXgMbEAvqtG+Lzb9JOojwQzIGDtN78Az2dLttkb71akWYUF/8IgYfDSlfKh4Uot8is5Vw=="], - "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.132.0.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-CmX/ulNBOEwWTyVRmcpYKAcAizW6+OjtLJgo7fXoL9OqQvjF4VER8tPomv44vwzfSCy1BHbsB0ZlZYzYJNj4cA=="], + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.132.0.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-CmX/ulNBOEwWTyVRmcpYKAcAizW6+OjtLJgo7fXoL9OqQvjF4VER8tPomv44vwzfSCy1BHbsB0ZlZYzYJNj4cA=="], - "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.132.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-j9oQS+hM90SdhviNGWbPgT4+Rlq+ac++q/zjgwPD1mVHgxHzATvoRGtDx0sXGmFOQ9J9YkwAhYGb5MAHL6TAsA=="], + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.132.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-j9oQS+hM90SdhviNGWbPgT4+Rlq+ac++q/zjgwPD1mVHgxHzATvoRGtDx0sXGmFOQ9J9YkwAhYGb5MAHL6TAsA=="], - "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.132.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-bLz+Xi+Agnfmd7kWPEsSVwCn2k4EyIalZkNBcQ0OGIv9rqn8VgCPLNd03tM9mKX/5TdlvDXalz0q71BIrOPNqg=="], + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.132.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-bLz+Xi+Agnfmd7kWPEsSVwCn2k4EyIalZkNBcQ0OGIv9rqn8VgCPLNd03tM9mKX/5TdlvDXalz0q71BIrOPNqg=="], - "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.132.0.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-U6t2qbJU0ypTfyj9QV3W1Y6mITDTL8ai/OR6NUn85vyHthOvobKWgXzU4tu0EskSzlpuVFz1g0jFGulDIUKHxQ=="], + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.132.0.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-U6t2qbJU0ypTfyj9QV3W1Y6mITDTL8ai/OR6NUn85vyHthOvobKWgXzU4tu0EskSzlpuVFz1g0jFGulDIUKHxQ=="], - "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.132.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-WcEaSNHFk8yz5YFlQQAlhq6jOFmZBB/RKE7uzhyCIf+pF1Lmv9gUH4221mle2Gd9iHyWT3ySNph8yZgb1xYdWg=="], + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.132.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-WcEaSNHFk8yz5YFlQQAlhq6jOFmZBB/RKE7uzhyCIf+pF1Lmv9gUH4221mle2Gd9iHyWT3ySNph8yZgb1xYdWg=="], - "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.132.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-iQrV4iJzQgRwK3BWRmQl1C3C6g3wYpXN2WLdQdyR+efoUnncdShZAVp9OgcojtlD3MDRbuOMGG3SjxF4fL4nlQ=="], + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.132.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-iQrV4iJzQgRwK3BWRmQl1C3C6g3wYpXN2WLdQdyR+efoUnncdShZAVp9OgcojtlD3MDRbuOMGG3SjxF4fL4nlQ=="], - "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.132.0.tgz", { "os": "none", "cpu": "arm64" }, "sha512-FWzmUGrZ6GUby4U7WIwcCtab6tdmlTO3xTRRKyb5kjIJVEiaUAT8animUG/nK8ZCA8gkRkPOTId4rl6uTqUmJQ=="], + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.132.0.tgz", { "os": "none", "cpu": "arm64" }, "sha512-FWzmUGrZ6GUby4U7WIwcCtab6tdmlTO3xTRRKyb5kjIJVEiaUAT8animUG/nK8ZCA8gkRkPOTId4rl6uTqUmJQ=="], - "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.132.0.tgz", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-TlbMppxJI5CjWDes0QaP6G3aneVg1yikBu5QYI+DUShF9WDL66ccgKFNNGmi/Wybtszw6hxwAvv76T4DaPKnHw=="], + "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.132.0.tgz", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-TlbMppxJI5CjWDes0QaP6G3aneVg1yikBu5QYI+DUShF9WDL66ccgKFNNGmi/Wybtszw6hxwAvv76T4DaPKnHw=="], - "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.132.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-RH/NbFjGKqdUAUi7Oh3LQPxUk2hsWFEEQ38HSnbRQT8QjBZFKqL1fMbmsB3N4jy/KPh9iX94+9dmkEMBBbambw=="], + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.132.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-RH/NbFjGKqdUAUi7Oh3LQPxUk2hsWFEEQ38HSnbRQT8QjBZFKqL1fMbmsB3N4jy/KPh9iX94+9dmkEMBBbambw=="], - "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.132.0.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-JUr4jQY9jxoIB/YTLXr6XofSi5xikj6p5/Ns1h0VOBDT0j1jKU+kMsv2xxv51RwnETcXpA1Yw/9oUAfcqfaqEA=="], + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.132.0.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-JUr4jQY9jxoIB/YTLXr6XofSi5xikj6p5/Ns1h0VOBDT0j1jKU+kMsv2xxv51RwnETcXpA1Yw/9oUAfcqfaqEA=="], - "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.132.0", "https://registry.npmmirror.com/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.132.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-2dapgHpA5X8DSXF4AU36hJWYf6zP0tKjMXFRAZFBD62pkevW/uhFDXoFH9Y/3Fd2EtDrw5ByNnR1wVE9X9y0SQ=="], + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.132.0", "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.132.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-2dapgHpA5X8DSXF4AU36hJWYf6zP0tKjMXFRAZFBD62pkevW/uhFDXoFH9Y/3Fd2EtDrw5ByNnR1wVE9X9y0SQ=="], - "@oxc-project/runtime": ["@oxc-project/runtime@0.136.0", "https://registry.npmmirror.com/@oxc-project/runtime/-/runtime-0.136.0.tgz", {}, "sha512-u0EutjK5y6NHJkl5jNJCs8zbup1z6A/UEWgajrYzqcEU3UX05HjqybhMQOLhSM0eKGISyM6WfSMMuklYSmH2wA=="], + "@oxc-project/runtime": ["@oxc-project/runtime@0.136.0", "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.136.0.tgz", {}, "sha512-u0EutjK5y6NHJkl5jNJCs8zbup1z6A/UEWgajrYzqcEU3UX05HjqybhMQOLhSM0eKGISyM6WfSMMuklYSmH2wA=="], - "@oxc-project/types": ["@oxc-project/types@0.136.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.136.0.tgz", {}, "sha512-39Al/B3v9esnHCX7S8l9Se2+s2tb9b2jcMd+bZ2L659VG73kNyGPpPrL5Zi/p0ty7p4pTTU2/Dd+g27hv94XCg=="], + "@oxc-project/types": ["@oxc-project/types@0.136.0", "https://registry.npmjs.org/@oxc-project/types/-/types-0.136.0.tgz", {}, "sha512-39Al/B3v9esnHCX7S8l9Se2+s2tb9b2jcMd+bZ2L659VG73kNyGPpPrL5Zi/p0ty7p4pTTU2/Dd+g27hv94XCg=="], - "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.21.3.tgz", { "os": "android", "cpu": "arm" }, "sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg=="], + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.21.3.tgz", { "os": "android", "cpu": "arm" }, "sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg=="], - "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.21.3.tgz", { "os": "android", "cpu": "arm64" }, "sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw=="], + "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.21.3.tgz", { "os": "android", "cpu": "arm64" }, "sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw=="], - "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.21.3.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg=="], + "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.21.3.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg=="], - "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.21.3.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg=="], + "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.21.3.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg=="], - "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.21.3.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw=="], + "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.21.3.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw=="], - "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.21.3.tgz", { "os": "linux", "cpu": "arm" }, "sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ=="], + "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.21.3.tgz", { "os": "linux", "cpu": "arm" }, "sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ=="], - "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.21.3.tgz", { "os": "linux", "cpu": "arm" }, "sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ=="], + "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.21.3.tgz", { "os": "linux", "cpu": "arm" }, "sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ=="], - "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.21.3.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ=="], + "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.21.3.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ=="], - "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.21.3.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw=="], + "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.21.3.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw=="], - "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.21.3.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A=="], + "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.21.3.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A=="], - "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.21.3.tgz", { "os": "linux", "cpu": "none" }, "sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og=="], + "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.21.3.tgz", { "os": "linux", "cpu": "none" }, "sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og=="], - "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.21.3.tgz", { "os": "linux", "cpu": "none" }, "sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw=="], + "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.21.3.tgz", { "os": "linux", "cpu": "none" }, "sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw=="], - "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.21.3.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q=="], + "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.21.3.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q=="], - "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.21.3.tgz", { "os": "linux", "cpu": "x64" }, "sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q=="], + "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.21.3.tgz", { "os": "linux", "cpu": "x64" }, "sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q=="], - "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.21.3.tgz", { "os": "linux", "cpu": "x64" }, "sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA=="], + "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.21.3.tgz", { "os": "linux", "cpu": "x64" }, "sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA=="], - "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.21.3.tgz", { "os": "none", "cpu": "arm64" }, "sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg=="], + "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.21.3.tgz", { "os": "none", "cpu": "arm64" }, "sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg=="], - "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.21.3.tgz", { "dependencies": { "@emnapi/core": "1.11.0", "@emnapi/runtime": "1.11.0", "@napi-rs/wasm-runtime": "^1.1.5" }, "cpu": "none" }, "sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA=="], + "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.21.3.tgz", { "dependencies": { "@emnapi/core": "1.11.0", "@emnapi/runtime": "1.11.0", "@napi-rs/wasm-runtime": "^1.1.5" }, "cpu": "none" }, "sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA=="], - "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.21.3.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg=="], + "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.21.3.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg=="], - "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.21.3", "https://registry.npmmirror.com/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.21.3.tgz", { "os": "win32", "cpu": "x64" }, "sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q=="], + "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.21.3", "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.21.3.tgz", { "os": "win32", "cpu": "x64" }, "sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q=="], - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.55.0.tgz", { "os": "android", "cpu": "arm" }, "sha512-+rFDOqQe5LOWgxrAJaZgLRudr6GQm0wGI6gtu7vVkrdLGjNMUSGbAlaCr8j7F2H2Er97vYQCU8WDb30onqMM1g=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.55.0.tgz", { "os": "android", "cpu": "arm" }, "sha512-+rFDOqQe5LOWgxrAJaZgLRudr6GQm0wGI6gtu7vVkrdLGjNMUSGbAlaCr8j7F2H2Er97vYQCU8WDb30onqMM1g=="], - "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.55.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-ctulLq8s3x8Zmvw6+iccB09TIKERAklRSmbJ10gk8mlAn05qZxoyo52dj3Hi9IJcmDSwF54fQaTVh2CbL6PInw=="], + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.55.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-ctulLq8s3x8Zmvw6+iccB09TIKERAklRSmbJ10gk8mlAn05qZxoyo52dj3Hi9IJcmDSwF54fQaTVh2CbL6PInw=="], - "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.55.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-xDQczLH9pw/RBk1h/GH0qcGMm8hQtmtVHBNLSH3lk1gEIR09hZ4L+mJQl4VqiVAvPK9VG9PYrWWuSQLt7xTbiA=="], + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.55.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-xDQczLH9pw/RBk1h/GH0qcGMm8hQtmtVHBNLSH3lk1gEIR09hZ4L+mJQl4VqiVAvPK9VG9PYrWWuSQLt7xTbiA=="], - "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.55.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-JaNoFCkF2CJdGgpPSMbuO9HVyXyoNGIhMHPvp6NYAjeVKw9XEYc0HcUWJLPQa3Q69WV5wMa9m5jPMJPtbLtcRg=="], + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.55.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-JaNoFCkF2CJdGgpPSMbuO9HVyXyoNGIhMHPvp6NYAjeVKw9XEYc0HcUWJLPQa3Q69WV5wMa9m5jPMJPtbLtcRg=="], - "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.55.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-DNbszhpg6S2MIzax5azdHFTTBIVkR5xr8yyRZuA4yoDAwOkzIp3tmldgKZM2+VlT+hJIG0xUksA+elISzMEAfA=="], + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.55.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-DNbszhpg6S2MIzax5azdHFTTBIVkR5xr8yyRZuA4yoDAwOkzIp3tmldgKZM2+VlT+hJIG0xUksA+elISzMEAfA=="], - "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.55.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-2snoaoRfFFyGnbOcKUK36rREBYxe/Xgz3uHbiA5zbCB/s6R4DQj4mHqYAaWWhgizCUSDxV8cE9zAZ0XleNpKGw=="], + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.55.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-2snoaoRfFFyGnbOcKUK36rREBYxe/Xgz3uHbiA5zbCB/s6R4DQj4mHqYAaWWhgizCUSDxV8cE9zAZ0XleNpKGw=="], - "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.55.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-q1aktHF/WRpSK81BX1dE/9vWrS2jGw1Nax2kb4DBLGAewubCLcoNyp4Zl/NSMgbv3vUS46Z33wIQkBVYOP3PYg=="], + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.55.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-q1aktHF/WRpSK81BX1dE/9vWrS2jGw1Nax2kb4DBLGAewubCLcoNyp4Zl/NSMgbv3vUS46Z33wIQkBVYOP3PYg=="], - "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.55.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-VD0y36aENezl/3tsclA/4G53Cc7iV+7Uoh7gz4yvcOTaEYBtJpQsE6PKDGTtUtOvGS4kv51ybfXY/nWZejO5IA=="], + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.55.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-VD0y36aENezl/3tsclA/4G53Cc7iV+7Uoh7gz4yvcOTaEYBtJpQsE6PKDGTtUtOvGS4kv51ybfXY/nWZejO5IA=="], - "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.55.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-r8xlKJFcsRmn0H5jZrdORae6RX9jDBrZVvOoxF+bCQtampQJClv80aZEHsv+NsLsp2KCE5ql79O7DpPVzYWpXA=="], + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.55.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-r8xlKJFcsRmn0H5jZrdORae6RX9jDBrZVvOoxF+bCQtampQJClv80aZEHsv+NsLsp2KCE5ql79O7DpPVzYWpXA=="], - "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.55.0.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-GRKv/HXHcwIVld/WU61rF0g0R16hl5EJ+ScKdpjevT57lnLnagj/U2YUbXf2mT+2Pg1uCzWC+mvGicPV3CDdLQ=="], + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.55.0.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-GRKv/HXHcwIVld/WU61rF0g0R16hl5EJ+ScKdpjevT57lnLnagj/U2YUbXf2mT+2Pg1uCzWC+mvGicPV3CDdLQ=="], - "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.55.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-rdv57enTiPtpSYRMKfAiEbQb0Puw5t9N7isVinDoo5qeLDScro2gznmZqSgSWbVZRzLisTeCTW8Qwgw0bOHv3A=="], + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.55.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-rdv57enTiPtpSYRMKfAiEbQb0Puw5t9N7isVinDoo5qeLDScro2gznmZqSgSWbVZRzLisTeCTW8Qwgw0bOHv3A=="], - "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.55.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-7v1nNrlD43VY6+sYQ6efYyb3lE6QY182304PD/768ZxTjOmFd/3dQa3u/nGBUAXYdGSWOQc5N3PnS0QzUXyEIA=="], + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.55.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-7v1nNrlD43VY6+sYQ6efYyb3lE6QY182304PD/768ZxTjOmFd/3dQa3u/nGBUAXYdGSWOQc5N3PnS0QzUXyEIA=="], - "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.55.0.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-f4lJLUSPOgScjFl9LiflKCTocyNRwE25JmTMbN4XQdDjoZzEHjqf3wA3VESF1/csg7i8m7+EQLbrZyYDqe10UQ=="], + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.55.0.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-f4lJLUSPOgScjFl9LiflKCTocyNRwE25JmTMbN4XQdDjoZzEHjqf3wA3VESF1/csg7i8m7+EQLbrZyYDqe10UQ=="], - "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.55.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-MihqiPziJNoWy4MqNSV+jVA1g+07iQDjZiR0vaCaDoPgFEiJpCMsxamktzLV07cEeQsSJ04vQaU4CzCQwIvtDA=="], + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.55.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-MihqiPziJNoWy4MqNSV+jVA1g+07iQDjZiR0vaCaDoPgFEiJpCMsxamktzLV07cEeQsSJ04vQaU4CzCQwIvtDA=="], - "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.55.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Yqghym7KYAVjP9MmSrNZiDeerMuoejNjo0r3ox5H3GDKk8eAfl8VyJm9i+pWCLDCTnAbcTUMMN2ZKjUYXH1v3g=="], + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.55.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Yqghym7KYAVjP9MmSrNZiDeerMuoejNjo0r3ox5H3GDKk8eAfl8VyJm9i+pWCLDCTnAbcTUMMN2ZKjUYXH1v3g=="], - "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.55.0.tgz", { "os": "none", "cpu": "arm64" }, "sha512-s5SDvVVSbyQl1V5UU3Yl12M+XLUQ3rl5SglNqgAA2K4PXUtQhyNSS00wivONPEnNo5W01rCou8WkDNyvI/RGHg=="], + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.55.0.tgz", { "os": "none", "cpu": "arm64" }, "sha512-s5SDvVVSbyQl1V5UU3Yl12M+XLUQ3rl5SglNqgAA2K4PXUtQhyNSS00wivONPEnNo5W01rCou8WkDNyvI/RGHg=="], - "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.55.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-7p9FB5R32tw2KyyNX3wpQrR2WHwEHvMEiBlGXxeTCaRMCVNx3UtFMAUbaQ/pRNWIrEUZmYhJ6tcUH52uPTRYjQ=="], + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.55.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-7p9FB5R32tw2KyyNX3wpQrR2WHwEHvMEiBlGXxeTCaRMCVNx3UtFMAUbaQ/pRNWIrEUZmYhJ6tcUH52uPTRYjQ=="], - "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.55.0.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-ZYqj3fDnOT1IaVGMP5kpmkQl4F3tQIm2ZyAxvqkJYmI0xgWWak4ss4XYwv3VDfM+TWXeC9K4uQ/wW5jm/5XABA=="], + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.55.0.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-ZYqj3fDnOT1IaVGMP5kpmkQl4F3tQIm2ZyAxvqkJYmI0xgWWak4ss4XYwv3VDfM+TWXeC9K4uQ/wW5jm/5XABA=="], - "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.55.0", "https://registry.npmmirror.com/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.55.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-eEYT5tivGnGbPHuOHuQpi6CGLObhh0re/5jcNQHihD2GRYkTM85dyi5a19zjP8Q00t1uqAx+/QGLUGdHeqzWyg=="], + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.55.0", "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.55.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-eEYT5tivGnGbPHuOHuQpi6CGLObhh0re/5jcNQHihD2GRYkTM85dyi5a19zjP8Q00t1uqAx+/QGLUGdHeqzWyg=="], - "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.23.0", "https://registry.npmmirror.com/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.23.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.23.0", "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.23.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw=="], - "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.23.0", "https://registry.npmmirror.com/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.23.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA=="], + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.23.0", "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.23.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA=="], - "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.23.0", "https://registry.npmmirror.com/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.23.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw=="], + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.23.0", "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.23.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw=="], - "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.23.0", "https://registry.npmmirror.com/@oxlint-tsgolint/linux-x64/-/linux-x64-0.23.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA=="], + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.23.0", "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.23.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA=="], - "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.23.0", "https://registry.npmmirror.com/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.23.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw=="], + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.23.0", "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.23.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw=="], - "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.23.0", "https://registry.npmmirror.com/@oxlint-tsgolint/win32-x64/-/win32-x64-0.23.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ=="], + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.23.0", "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.23.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.70.0.tgz", { "os": "android", "cpu": "arm" }, "sha512-zFh0P4cswmRvw6nkyb89dr18rRanuaCPAsEXsFDoQY8WdaquI8Pt4NWFjaMJg6L23cy5NeN8J9cBnREbWzZhaw=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.70.0", "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.70.0.tgz", { "os": "android", "cpu": "arm" }, "sha512-zFh0P4cswmRvw6nkyb89dr18rRanuaCPAsEXsFDoQY8WdaquI8Pt4NWFjaMJg6L23cy5NeN8J9cBnREbWzZhaw=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-android-arm64/-/binding-android-arm64-1.70.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-qI8o4HZjeGiBrWv+pJv4lH0Yi2Gl/JSp/EumBUApezJprIKa5PS4nU0lQsQngtky8k+SplQIOjv6hwu0SSxeyg=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.70.0", "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.70.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-qI8o4HZjeGiBrWv+pJv4lH0Yi2Gl/JSp/EumBUApezJprIKa5PS4nU0lQsQngtky8k+SplQIOjv6hwu0SSxeyg=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.70.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-8KjgVVHI5F9nVwHCRwwA78Ty7zNKP4Wd9OeN5PSv3iu/F/u1RVXoOCgLhWqust6HmwQG6xc8c+RCyaWENy24+w=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.70.0", "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.70.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-8KjgVVHI5F9nVwHCRwwA78Ty7zNKP4Wd9OeN5PSv3iu/F/u1RVXoOCgLhWqust6HmwQG6xc8c+RCyaWENy24+w=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.70.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-WVydssv5PSUBXFJTdNBWlmGkbNmvPGaFt/2SUT/EZRB6bq6bEOHmMlbnupZD5jmlEvi9+mZJHi8TCw15lyfSfQ=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.70.0", "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.70.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-WVydssv5PSUBXFJTdNBWlmGkbNmvPGaFt/2SUT/EZRB6bq6bEOHmMlbnupZD5jmlEvi9+mZJHi8TCw15lyfSfQ=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.70.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-hJucmUf8OlinHNb1R7fI4Fw6WsAstOz7i8nmkWQfiHoZXtbufNm+MxiDTIMk1ggh2Ro4vLzgQ+bKvRY54MZoRA=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.70.0", "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.70.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-hJucmUf8OlinHNb1R7fI4Fw6WsAstOz7i8nmkWQfiHoZXtbufNm+MxiDTIMk1ggh2Ro4vLzgQ+bKvRY54MZoRA=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.70.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-1BnS7wbCYDSXwWzJJ+mc3NURoha6m6m6RT5c6vgAY3oz7C3OVXP+S0awo2mRq97arrJkVvO3qRQfyAHL+76xtQ=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.70.0", "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.70.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-1BnS7wbCYDSXwWzJJ+mc3NURoha6m6m6RT5c6vgAY3oz7C3OVXP+S0awo2mRq97arrJkVvO3qRQfyAHL+76xtQ=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.70.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-yKy/UdbR55+M2yEcuiV5DCNC/gdQAjr/GioUy50QwBzSrKm8ueWADqyRLS9Xk+qjNeCYGg6A8FvUBds56ttfqg=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.70.0", "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.70.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-yKy/UdbR55+M2yEcuiV5DCNC/gdQAjr/GioUy50QwBzSrKm8ueWADqyRLS9Xk+qjNeCYGg6A8FvUBds56ttfqg=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.70.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-0A5XJ4alvmqFUFP/4oYSyaO+qLto/HrKEWTSaegiVl+HOufFngK2BjYw9x4RbwBt/du5QG6l5q1zeWiJYYG5yg=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.70.0", "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.70.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-0A5XJ4alvmqFUFP/4oYSyaO+qLto/HrKEWTSaegiVl+HOufFngK2BjYw9x4RbwBt/du5QG6l5q1zeWiJYYG5yg=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.70.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-JiylyurlB0CLSedNtx1gzv3FvfWPF1h/2Y3BJszPLNt5XQFlBsH5ke0Jle3iJb3uqu5m2e7A/DwzpuCAHdiU+A=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.70.0", "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.70.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-JiylyurlB0CLSedNtx1gzv3FvfWPF1h/2Y3BJszPLNt5XQFlBsH5ke0Jle3iJb3uqu5m2e7A/DwzpuCAHdiU+A=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.70.0.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-J8VPG7I3/HmgaU4u8pNU2kFx2+0U+vPLS1dXFxXOaR/2TQ0f8AC7DRz0SRGRI1bfphnX2hVYTTtLuhL4nYKL+Q=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.70.0", "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.70.0.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-J8VPG7I3/HmgaU4u8pNU2kFx2+0U+vPLS1dXFxXOaR/2TQ0f8AC7DRz0SRGRI1bfphnX2hVYTTtLuhL4nYKL+Q=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.70.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-N2+4lV2KLN+oXTIIIwmWDhwkrnvqf5oX7Hw0zPjk+RuIVgiBQSOlJWF7uQoFx2siEYX0ZQ5cfSbEAHm+J3t7Wg=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.70.0", "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.70.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-N2+4lV2KLN+oXTIIIwmWDhwkrnvqf5oX7Hw0zPjk+RuIVgiBQSOlJWF7uQoFx2siEYX0ZQ5cfSbEAHm+J3t7Wg=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.70.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-1e2L7cFCvx9QDzq6NPP+0tABKb5z6nWHyddWTNKprEsjO9xNrAtPowuCGpjNXxkTdsMiZ4jc8YQ5SstZd4XK6g=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.70.0", "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.70.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-1e2L7cFCvx9QDzq6NPP+0tABKb5z6nWHyddWTNKprEsjO9xNrAtPowuCGpjNXxkTdsMiZ4jc8YQ5SstZd4XK6g=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.70.0.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-Kwu/l/8GcYibCWA9m9N5pRXMIKVSsL/YbgpLzYkqDhWTiqdRfnNJ/+nqIKRKQiFbHWsdlHEhzMwruJK+qcEruA=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.70.0", "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.70.0.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-Kwu/l/8GcYibCWA9m9N5pRXMIKVSsL/YbgpLzYkqDhWTiqdRfnNJ/+nqIKRKQiFbHWsdlHEhzMwruJK+qcEruA=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.70.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-tap04CsHYOl0nSAQJfPNIuBxqEPB2HnhQqwaOXLg1jnp2XfRo8Fa814dA4QC4zpvTWXCjAAaCY1W5LOORkEQuQ=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.70.0", "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.70.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-tap04CsHYOl0nSAQJfPNIuBxqEPB2HnhQqwaOXLg1jnp2XfRo8Fa814dA4QC4zpvTWXCjAAaCY1W5LOORkEQuQ=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.70.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-hzJa/WgvtJpbBD9rgfy0qe+MjbxOXNUT0bfR1S6EQQzfTtBFA9xg5q8KSwRrQ2QfSS+TaP4j+4mVPQrfNc6UNg=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.70.0", "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.70.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-hzJa/WgvtJpbBD9rgfy0qe+MjbxOXNUT0bfR1S6EQQzfTtBFA9xg5q8KSwRrQ2QfSS+TaP4j+4mVPQrfNc6UNg=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.70.0.tgz", { "os": "none", "cpu": "arm64" }, "sha512-xbsaNSNzVSnaJACCUYr1HQMyY/Q/Q1LkePmHG3UvZPvGCYGNxrsZp9OmtA6ick8xH47ltRRbRrPCM1YXYcyC+A=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.70.0", "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.70.0.tgz", { "os": "none", "cpu": "arm64" }, "sha512-xbsaNSNzVSnaJACCUYr1HQMyY/Q/Q1LkePmHG3UvZPvGCYGNxrsZp9OmtA6ick8xH47ltRRbRrPCM1YXYcyC+A=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.70.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-icAEsUI7JbW1TMRdEXV83mVAInhRVQYuuAlPpxdGwJ95chNdnCzjloRW8GglT0WvzOEZSio6fnYSk2DJ2Hv7LQ=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.70.0", "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.70.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-icAEsUI7JbW1TMRdEXV83mVAInhRVQYuuAlPpxdGwJ95chNdnCzjloRW8GglT0WvzOEZSio6fnYSk2DJ2Hv7LQ=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.70.0.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-FHMSWbVsPVs/f+Jcl04ws4JJ2wUnauyTzlpxWRG/lSO/8GpX08Fo2gQZqdA6CrRFI+zvkxl+N/KwJGWfUwYVZA=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.70.0", "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.70.0.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-FHMSWbVsPVs/f+Jcl04ws4JJ2wUnauyTzlpxWRG/lSO/8GpX08Fo2gQZqdA6CrRFI+zvkxl+N/KwJGWfUwYVZA=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.70.0", "https://registry.npmmirror.com/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.70.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-ptOlKwCz7n4AKs5VweMqG6DAg677FmKOK+vBkkL9DMNgFATIQ+upqUYBTOEwRQyRAx1ncGlPlXleV2hIcm3z4g=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.70.0", "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.70.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-ptOlKwCz7n4AKs5VweMqG6DAg677FmKOK+vBkkL9DMNgFATIQ+upqUYBTOEwRQyRAx1ncGlPlXleV2hIcm3z4g=="], - "@oxlint/plugins": ["@oxlint/plugins@1.68.0", "https://registry.npmmirror.com/@oxlint/plugins/-/plugins-1.68.0.tgz", {}, "sha512-titLmukUt/h8ho7Svlf0xSBjoy2ccZKrXjpXpZCj+v6V4CJccC2KyP45BLSCMx8YIpifMyiDyUptM4+5sruKbQ=="], + "@oxlint/plugins": ["@oxlint/plugins@1.68.0", "https://registry.npmjs.org/@oxlint/plugins/-/plugins-1.68.0.tgz", {}, "sha512-titLmukUt/h8ho7Svlf0xSBjoy2ccZKrXjpXpZCj+v6V4CJccC2KyP45BLSCMx8YIpifMyiDyUptM4+5sruKbQ=="], - "@polka/url": ["@polka/url@1.0.0-next.29", "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.29.tgz", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], - "@preact/signals": ["@preact/signals@2.9.2", "https://registry.npmmirror.com/@preact/signals/-/signals-2.9.2.tgz", { "dependencies": { "@preact/signals-core": "^1.14.3" }, "peerDependencies": { "preact": ">= 10.25.0 || >=11.0.0-0" } }, "sha512-DvFPISNMSh3vPqRwPa1tAVAHl85aDq4pTyNu1bTGfrKr64F3EOCHjdUl9aUdohKBf1v9PRGLYuGFcJpfztkdoQ=="], + "@preact/signals": ["@preact/signals@2.9.2", "https://registry.npmjs.org/@preact/signals/-/signals-2.9.2.tgz", { "dependencies": { "@preact/signals-core": "^1.14.3" }, "peerDependencies": { "preact": ">= 10.25.0 || >=11.0.0-0" } }, "sha512-DvFPISNMSh3vPqRwPa1tAVAHl85aDq4pTyNu1bTGfrKr64F3EOCHjdUl9aUdohKBf1v9PRGLYuGFcJpfztkdoQ=="], - "@preact/signals-core": ["@preact/signals-core@1.14.3", "https://registry.npmmirror.com/@preact/signals-core/-/signals-core-1.14.3.tgz", {}, "sha512-m0K3vnbSLC5rHs2ZVfeAMvBtT1zIyq4mxx5OlNncSgMj5Iz6W5Rn3kPrDxAC+iIKmiVe0lSl6U37t5ZkEWoVAw=="], + "@preact/signals-core": ["@preact/signals-core@1.14.3", "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.3.tgz", {}, "sha512-m0K3vnbSLC5rHs2ZVfeAMvBtT1zIyq4mxx5OlNncSgMj5Iz6W5Rn3kPrDxAC+iIKmiVe0lSl6U37t5ZkEWoVAw=="], - "@radix-ui/colors": ["@radix-ui/colors@3.0.0", "https://registry.npmmirror.com/@radix-ui/colors/-/colors-3.0.0.tgz", {}, "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg=="], + "@radix-ui/colors": ["@radix-ui/colors@3.0.0", "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz", {}, "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg=="], - "@radix-ui/number": ["@radix-ui/number@1.1.3", "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.3.tgz", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="], + "@radix-ui/number": ["@radix-ui/number@1.1.3", "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="], - "@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.7.tgz", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], - "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.15", "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA=="], - "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA=="], + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.15", "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA=="], - "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], - "@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.2.2.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="], + "@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="], - "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.23", "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA=="], + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.23", "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA=="], - "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="], + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="], - "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="], + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="], - "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.6", "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ=="], + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.6", "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ=="], - "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.16", "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ=="], + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.16", "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ=="], - "@radix-ui/react-icons": ["@radix-ui/react-icons@1.3.2", "https://registry.npmmirror.com/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", { "peerDependencies": { "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" } }, "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g=="], + "@radix-ui/react-icons": ["@radix-ui/react-icons@1.3.2", "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", { "peerDependencies": { "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" } }, "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g=="], - "@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.4.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], - "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.23", "https://registry.npmmirror.com/@radix-ui/react-popover/-/react-popover-1.1.23.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ=="], + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.23", "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.23.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ=="], - "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.7", "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", "@radix-ui/react-use-size": "1.1.4", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg=="], + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.7", "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", "@radix-ui/react-use-size": "1.1.4", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg=="], - "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.17", "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ=="], + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.17", "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ=="], - "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="], + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="], - "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], - "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.19", "https://registry.npmmirror.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.19", "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ=="], - "@radix-ui/react-select": ["@radix-ui/react-select@2.3.7", "https://registry.npmmirror.com/@radix-ui/react-select/-/react-select-2.3.7.tgz", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg=="], + "@radix-ui/react-select": ["@radix-ui/react-select@2.3.7", "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg=="], - "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw=="], + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.15", "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw=="], - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="], - "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.18", "https://registry.npmmirror.com/@radix-ui/react-toggle/-/react-toggle-1.1.18.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug=="], + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.18", "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.18.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug=="], - "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.19", "https://registry.npmmirror.com/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.19.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-toggle": "1.1.18", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA=="], + "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.19", "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.19.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-toggle": "1.1.18", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA=="], - "@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.19", "https://registry.npmmirror.com/@radix-ui/react-toolbar/-/react-toolbar-1.1.19.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-separator": "1.1.15", "@radix-ui/react-toggle-group": "1.1.19" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw=="], + "@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.19", "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.19.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-separator": "1.1.15", "@radix-ui/react-toggle-group": "1.1.19" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw=="], - "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.16", "https://registry.npmmirror.com/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg=="], + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.16", "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg=="], - "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="], + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="], - "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="], + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="], - "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "https://registry.npmmirror.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="], + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="], - "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.3", "https://registry.npmmirror.com/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw=="], + "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.3", "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw=="], - "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="], + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="], - "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.4", "https://registry.npmmirror.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg=="], + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.4", "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg=="], - "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.4", "https://registry.npmmirror.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", { "dependencies": { "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ=="], + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.4", "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", { "dependencies": { "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ=="], - "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.4", "https://registry.npmmirror.com/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw=="], + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.4", "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw=="], - "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.11", "https://registry.npmmirror.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ=="], + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.11", "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ=="], - "@radix-ui/rect": ["@radix-ui/rect@1.1.3", "https://registry.npmmirror.com/@radix-ui/rect/-/rect-1.1.3.tgz", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="], + "@radix-ui/rect": ["@radix-ui/rect@1.1.3", "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="], - "@react-grab/cli": ["@react-grab/cli@0.1.47", "https://registry.npmmirror.com/@react-grab/cli/-/cli-0.1.47.tgz", { "dependencies": { "agent-install": "^0.0.6", "commander": "^14.0.3", "ignore": "^7.0.5", "ora": "^9.4.0", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "prompts": "^2.4.2", "tinyexec": "^1.1.2" }, "bin": { "react-grab": "bin/cli.js" } }, "sha512-Cc7d8mSwvoV8gpeTQbE8dMPdeXIyO6w+yIhzgi3jY06i03WLNhb/6jIxNBNF1cVRI7ujnFQXZA66BbnBNTpBSw=="], + "@react-grab/cli": ["@react-grab/cli@0.1.47", "https://registry.npmjs.org/@react-grab/cli/-/cli-0.1.47.tgz", { "dependencies": { "agent-install": "^0.0.6", "commander": "^14.0.3", "ignore": "^7.0.5", "ora": "^9.4.0", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "prompts": "^2.4.2", "tinyexec": "^1.1.2" }, "bin": { "react-grab": "bin/cli.js" } }, "sha512-Cc7d8mSwvoV8gpeTQbE8dMPdeXIyO6w+yIhzgi3jY06i03WLNhb/6jIxNBNF1cVRI7ujnFQXZA66BbnBNTpBSw=="], - "@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.4", "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", { "os": "android", "cpu": "arm64" }, "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.4", "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", { "os": "android", "cpu": "arm64" }, "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.4", "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.4", "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.4", "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.4", "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.4", "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.4", "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.4", "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", { "os": "linux", "cpu": "arm" }, "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.4", "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", { "os": "linux", "cpu": "arm" }, "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.4", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.4", "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.4", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.4", "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.4", "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.4", "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.4", "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.4", "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.4", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.4", "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.4", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.4", "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.4", "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", { "os": "none", "cpu": "arm64" }, "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.4", "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", { "os": "none", "cpu": "arm64" }, "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.4", "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.4", "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.4", "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", { "os": "win32", "cpu": "x64" }, "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.4", "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", { "os": "win32", "cpu": "x64" }, "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], + "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], - "@sentry/conventions": ["@sentry/conventions@0.12.0", "https://registry.npmmirror.com/@sentry/conventions/-/conventions-0.12.0.tgz", {}, "sha512-z1JQrl/1SLY+8wpzvork6vl+fpsg/oCCxM7HWWhUnI/R+OGNyoIzieQuggX3uUMY7NBtp8UWCQx6FeFazzOF9g=="], + "@sentry/conventions": ["@sentry/conventions@0.12.0", "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.12.0.tgz", {}, "sha512-z1JQrl/1SLY+8wpzvork6vl+fpsg/oCCxM7HWWhUnI/R+OGNyoIzieQuggX3uUMY7NBtp8UWCQx6FeFazzOF9g=="], - "@sentry/core": ["@sentry/core@10.60.0", "https://registry.npmmirror.com/@sentry/core/-/core-10.60.0.tgz", {}, "sha512-szN7ccOJAEaLb1BBQzCQhABGMTJmKNUk0G2sc7rWhajeXoZoMKIbNkI9RvJrFuV69cbad/d/BKGBjbpJhySAzw=="], + "@sentry/core": ["@sentry/core@10.60.0", "https://registry.npmjs.org/@sentry/core/-/core-10.60.0.tgz", {}, "sha512-szN7ccOJAEaLb1BBQzCQhABGMTJmKNUk0G2sc7rWhajeXoZoMKIbNkI9RvJrFuV69cbad/d/BKGBjbpJhySAzw=="], - "@sentry/node": ["@sentry/node@10.60.0", "https://registry.npmmirror.com/@sentry/node/-/node-10.60.0.tgz", { "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@sentry/core": "10.60.0", "@sentry/node-core": "10.60.0", "@sentry/opentelemetry": "10.60.0", "@sentry/server-utils": "10.60.0", "import-in-the-middle": "^3.0.0" } }, "sha512-u//paUrkKaCr0oNn7r7UulGydkYMSkU1wQOIpG/P/jf7psZWnyXhgeszHzUfZXo6pCdxXG9z9viPvzGjqPQN7A=="], + "@sentry/node": ["@sentry/node@10.60.0", "https://registry.npmjs.org/@sentry/node/-/node-10.60.0.tgz", { "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@sentry/core": "10.60.0", "@sentry/node-core": "10.60.0", "@sentry/opentelemetry": "10.60.0", "@sentry/server-utils": "10.60.0", "import-in-the-middle": "^3.0.0" } }, "sha512-u//paUrkKaCr0oNn7r7UulGydkYMSkU1wQOIpG/P/jf7psZWnyXhgeszHzUfZXo6pCdxXG9z9viPvzGjqPQN7A=="], - "@sentry/node-core": ["@sentry/node-core@10.60.0", "https://registry.npmmirror.com/@sentry/node-core/-/node-core-10.60.0.tgz", { "dependencies": { "@sentry/conventions": "^0.12.0", "@sentry/core": "10.60.0", "@sentry/opentelemetry": "10.60.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base"] }, "sha512-aXi9ixvP+hgUZPPZCRwMNHgY2I0gkSeoAKAUuysDJhWDmrygwfGdlkbGmmtW6PQjtMYFx69Igt5btvhjEBoJTw=="], + "@sentry/node-core": ["@sentry/node-core@10.60.0", "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.60.0.tgz", { "dependencies": { "@sentry/conventions": "^0.12.0", "@sentry/core": "10.60.0", "@sentry/opentelemetry": "10.60.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base"] }, "sha512-aXi9ixvP+hgUZPPZCRwMNHgY2I0gkSeoAKAUuysDJhWDmrygwfGdlkbGmmtW6PQjtMYFx69Igt5btvhjEBoJTw=="], - "@sentry/opentelemetry": ["@sentry/opentelemetry@10.60.0", "https://registry.npmmirror.com/@sentry/opentelemetry/-/opentelemetry-10.60.0.tgz", { "dependencies": { "@sentry/conventions": "^0.12.0", "@sentry/core": "10.60.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" } }, "sha512-gl+2NVH+9RmTu7pd9kV1tKif+Th+p9tmnXR1l3Sb3Wqo1ir5FaNMKrloWEKMXjnepii9EJUrEHdSC+i8NoexxQ=="], + "@sentry/opentelemetry": ["@sentry/opentelemetry@10.60.0", "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.60.0.tgz", { "dependencies": { "@sentry/conventions": "^0.12.0", "@sentry/core": "10.60.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" } }, "sha512-gl+2NVH+9RmTu7pd9kV1tKif+Th+p9tmnXR1l3Sb3Wqo1ir5FaNMKrloWEKMXjnepii9EJUrEHdSC+i8NoexxQ=="], - "@sentry/server-utils": ["@sentry/server-utils@10.60.0", "https://registry.npmmirror.com/@sentry/server-utils/-/server-utils-10.60.0.tgz", { "dependencies": { "@apm-js-collab/code-transformer": "^0.15.0", "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", "@apm-js-collab/tracing-hooks": "^0.10.0", "@sentry/conventions": "^0.12.0", "@sentry/core": "10.60.0", "magic-string": "~0.30.0" } }, "sha512-SX+MzWM3nz5ttKT48rlfktm0ERyIpDLma+b6pYeWgW2oFHKcpIu0g0qMGJrZs4lKM3MlgV7IqLa4texMqTp9kQ=="], + "@sentry/server-utils": ["@sentry/server-utils@10.60.0", "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.60.0.tgz", { "dependencies": { "@apm-js-collab/code-transformer": "^0.15.0", "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", "@apm-js-collab/tracing-hooks": "^0.10.0", "@sentry/conventions": "^0.12.0", "@sentry/core": "10.60.0", "magic-string": "~0.30.0" } }, "sha512-SX+MzWM3nz5ttKT48rlfktm0ERyIpDLma+b6pYeWgW2oFHKcpIu0g0qMGJrZs4lKM3MlgV7IqLa4texMqTp9kQ=="], - "@shadcn/react": ["@shadcn/react@0.2.1", "https://registry.npmmirror.com/@shadcn/react/-/react-0.2.1.tgz", { "peerDependencies": { "@types/react": ">=19", "react": ">=19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-5krgi3dRMKb5jH6a+qPzVJUy/54s0kKE4Rw4LjDfLqOdVQTWKUgxWf1kW8r912I0jX/Lzxqc+pgjkjWxUIK5BQ=="], + "@shadcn/react": ["@shadcn/react@0.2.1", "https://registry.npmjs.org/@shadcn/react/-/react-0.2.1.tgz", { "peerDependencies": { "@types/react": ">=19", "react": ">=19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-5krgi3dRMKb5jH6a+qPzVJUy/54s0kKE4Rw4LjDfLqOdVQTWKUgxWf1kW8r912I0jX/Lzxqc+pgjkjWxUIK5BQ=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], - "@tailwindcss/node": ["@tailwindcss/node@4.3.1", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.1.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.1" } }, "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.1", "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.1" } }, "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.1", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.3.1.tgz", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.1", "@tailwindcss/oxide-darwin-arm64": "4.3.1", "@tailwindcss/oxide-darwin-x64": "4.3.1", "@tailwindcss/oxide-freebsd-x64": "4.3.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", "@tailwindcss/oxide-linux-x64-musl": "4.3.1", "@tailwindcss/oxide-wasm32-wasi": "4.3.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.1", "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.1", "@tailwindcss/oxide-darwin-arm64": "4.3.1", "@tailwindcss/oxide-darwin-x64": "4.3.1", "@tailwindcss/oxide-freebsd-x64": "4.3.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", "@tailwindcss/oxide-linux-x64-musl": "4.3.1", "@tailwindcss/oxide-wasm32-wasi": "4.3.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.1", "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.1", "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.1", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.1", "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.1", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.1", "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.1", "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.1", "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1", "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.1", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.1", "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.1", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.1", "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.1", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.1", "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.1", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.1", "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.1", "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.1", "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.1", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.1", "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.1", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.1", "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA=="], - "@tailwindcss/vite": ["@tailwindcss/vite@4.3.1", "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.3.1.tgz", { "dependencies": { "@tailwindcss/node": "4.3.1", "@tailwindcss/oxide": "4.3.1", "tailwindcss": "4.3.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.1", "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", { "dependencies": { "@tailwindcss/node": "4.3.1", "@tailwindcss/oxide": "4.3.1", "tailwindcss": "4.3.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ=="], - "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.4", "https://registry.npmmirror.com/@tanstack/react-virtual/-/react-virtual-3.14.4.tgz", { "dependencies": { "@tanstack/virtual-core": "3.17.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dZzAQP2uCDAd+9sAehqmx/DcU+B91Q4Gb0aDSM7t9bJvWDyGF9sapFNW5r1gNLsHs4wTb6ScZENJeYaHxJLiOw=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.4", "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.4.tgz", { "dependencies": { "@tanstack/virtual-core": "3.17.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dZzAQP2uCDAd+9sAehqmx/DcU+B91Q4Gb0aDSM7t9bJvWDyGF9sapFNW5r1gNLsHs4wTb6ScZENJeYaHxJLiOw=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.2", "https://registry.npmmirror.com/@tanstack/virtual-core/-/virtual-core-3.17.2.tgz", {}, "sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.2", "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.2.tgz", {}, "sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA=="], - "@tauri-apps/api": ["@tauri-apps/api@2.11.1", "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.11.1.tgz", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="], + "@tauri-apps/api": ["@tauri-apps/api@2.11.1", "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="], - "@tauri-apps/cli": ["@tauri-apps/cli@2.11.4", "https://registry.npmmirror.com/@tauri-apps/cli/-/cli-2.11.4.tgz", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.4", "@tauri-apps/cli-darwin-x64": "2.11.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", "@tauri-apps/cli-linux-arm64-musl": "2.11.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-musl": "2.11.4", "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", "@tauri-apps/cli-win32-x64-msvc": "2.11.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ=="], + "@tauri-apps/cli": ["@tauri-apps/cli@2.11.4", "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.4", "@tauri-apps/cli-darwin-x64": "2.11.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", "@tauri-apps/cli-linux-arm64-musl": "2.11.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-musl": "2.11.4", "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", "@tauri-apps/cli-win32-x64-msvc": "2.11.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ=="], - "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.4", "https://registry.npmmirror.com/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ=="], + "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.4", "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ=="], - "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.4", "https://registry.npmmirror.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A=="], + "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.4", "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A=="], - "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.4", "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", { "os": "linux", "cpu": "arm" }, "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ=="], + "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.4", "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", { "os": "linux", "cpu": "arm" }, "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ=="], - "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.4", "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA=="], + "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.4", "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA=="], - "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.4", "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw=="], + "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.4", "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw=="], - "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.4", "https://registry.npmmirror.com/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", { "os": "linux", "cpu": "none" }, "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ=="], + "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.4", "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", { "os": "linux", "cpu": "none" }, "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ=="], - "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.4", "https://registry.npmmirror.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ=="], + "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.4", "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ=="], - "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.4", "https://registry.npmmirror.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A=="], + "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.4", "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A=="], - "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.4", "https://registry.npmmirror.com/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ=="], + "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.4", "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ=="], - "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.4", "https://registry.npmmirror.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA=="], + "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.4", "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA=="], - "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="], + "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="], - "@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "https://registry.npmmirror.com/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.2.tgz", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="], + "@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.2.tgz", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="], - "@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.9", "https://registry.npmmirror.com/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.9.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA=="], + "@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.9", "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.9.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA=="], - "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.2", "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg=="], + "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.2", "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg=="], - "@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.5.1", "https://registry.npmmirror.com/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ=="], + "@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.5.1", "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ=="], - "@tauri-apps/plugin-http": ["@tauri-apps/plugin-http@2.5.9", "https://registry.npmmirror.com/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA=="], + "@tauri-apps/plugin-http": ["@tauri-apps/plugin-http@2.5.9", "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA=="], - "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "https://registry.npmmirror.com/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="], + "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="], - "@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "https://registry.npmmirror.com/@tauri-apps/plugin-os/-/plugin-os-2.3.2.tgz", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="], + "@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.3.2.tgz", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="], - "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "https://registry.npmmirror.com/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="], + "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="], - "@tauri-apps/plugin-shell": ["@tauri-apps/plugin-shell@2.3.5", "https://registry.npmmirror.com/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg=="], + "@tauri-apps/plugin-shell": ["@tauri-apps/plugin-shell@2.3.5", "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg=="], - "@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.4.4", "https://registry.npmmirror.com/@tauri-apps/plugin-store/-/plugin-store-2.4.4.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-oxSMaj/QpVfJcBMYX5aOQV94fWvga0MwQMfD6TLlbK2dh+ShPWAzefd8HWXhvOKjPRJdGVAkW7ZGO76JzzjaDA=="], + "@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.4.4", "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.4.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-oxSMaj/QpVfJcBMYX5aOQV94fWvga0MwQMfD6TLlbK2dh+ShPWAzefd8HWXhvOKjPRJdGVAkW7ZGO76JzzjaDA=="], - "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "https://registry.npmmirror.com/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="], + "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="], - "@testing-library/dom": ["@testing-library/dom@10.4.1", "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], - "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "https://registry.npmmirror.com/@testing-library/user-event/-/user-event-14.6.1.tgz", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], - "@tree-sitter-grammars/tree-sitter-markdown": ["@tree-sitter-grammars/tree-sitter-markdown@0.3.2", "https://registry.npmmirror.com/@tree-sitter-grammars/tree-sitter-markdown/-/tree-sitter-markdown-0.3.2.tgz", { "dependencies": { "node-addon-api": "^8.1.0", "node-gyp-build": "^4.8.1" }, "peerDependencies": { "tree-sitter": "^0.21.1" } }, "sha512-hQXCcDVvg2t4E8cn7zz6jjIBerzk9E9ZlHxJp5IrUOpY4s1YVpXJbMeWZks2/V7lmkPRnnkM8IrTbQ5ltwEOnA=="], + "@tree-sitter-grammars/tree-sitter-markdown": ["@tree-sitter-grammars/tree-sitter-markdown@0.3.2", "https://registry.npmjs.org/@tree-sitter-grammars/tree-sitter-markdown/-/tree-sitter-markdown-0.3.2.tgz", { "dependencies": { "node-addon-api": "^8.1.0", "node-gyp-build": "^4.8.1" }, "peerDependencies": { "tree-sitter": "^0.21.1" } }, "sha512-hQXCcDVvg2t4E8cn7zz6jjIBerzk9E9ZlHxJp5IrUOpY4s1YVpXJbMeWZks2/V7lmkPRnnkM8IrTbQ5ltwEOnA=="], "@tree-sitter-grammars/tree-sitter-vue": ["tree-sitter-vue@github:tree-sitter-grammars/tree-sitter-vue#ce8011a", { "dependencies": { "nan": "^2.18.0", "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4", "tree-sitter-html": "=0.23.2" } }, "tree-sitter-grammars-tree-sitter-vue-ce8011a", "sha512-AmirHA7JluyVR8JF4Y15nOUvZlqxrt6r7fZVCEryv5aZzZ9D+AN3GTK1ga3abcPLZi9uHvTid9QWFPKYVcFMeg=="], - "@tree-sitter-grammars/tree-sitter-yaml": ["@tree-sitter-grammars/tree-sitter-yaml@0.7.1", "https://registry.npmmirror.com/@tree-sitter-grammars/tree-sitter-yaml/-/tree-sitter-yaml-0.7.1.tgz", { "dependencies": { "node-addon-api": "^8.3.1", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.4" }, "optionalPeers": ["tree-sitter"] }, "sha512-AynBwkIoQCTgjDR33bDUp9Mqq+YTco0is3n5hRApMqG9of/6A4eQsfC1/uSEeHSUyMQSYawcAWamsexnVpIP4Q=="], + "@tree-sitter-grammars/tree-sitter-yaml": ["@tree-sitter-grammars/tree-sitter-yaml@0.7.1", "https://registry.npmjs.org/@tree-sitter-grammars/tree-sitter-yaml/-/tree-sitter-yaml-0.7.1.tgz", { "dependencies": { "node-addon-api": "^8.3.1", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.4" }, "optionalPeers": ["tree-sitter"] }, "sha512-AynBwkIoQCTgjDR33bDUp9Mqq+YTco0is3n5hRApMqG9of/6A4eQsfC1/uSEeHSUyMQSYawcAWamsexnVpIP4Q=="], - "@tree-sitter-grammars/tree-sitter-zig": ["@tree-sitter-grammars/tree-sitter-zig@1.1.2", "https://registry.npmmirror.com/@tree-sitter-grammars/tree-sitter-zig/-/tree-sitter-zig-1.1.2.tgz", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-J0L31HZ2isy3F5zb2g5QWQOv2r/pbruQNL9ADhuQv2pn5BQOzxt80WcEJaYXBeuJ8GHxVT42slpCna8k1c8LOw=="], + "@tree-sitter-grammars/tree-sitter-zig": ["@tree-sitter-grammars/tree-sitter-zig@1.1.2", "https://registry.npmjs.org/@tree-sitter-grammars/tree-sitter-zig/-/tree-sitter-zig-1.1.2.tgz", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-J0L31HZ2isy3F5zb2g5QWQOv2r/pbruQNL9ADhuQv2pn5BQOzxt80WcEJaYXBeuJ8GHxVT42slpCna8k1c8LOw=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], - "@types/aria-query": ["@types/aria-query@5.0.4", "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], - "@types/chai": ["@types/chai@5.2.3", "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/chai": ["@types/chai@5.2.3", "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], - "@types/d3-array": ["@types/d3-array@3.2.2", "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], - "@types/d3-color": ["@types/d3-color@3.1.3", "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + "@types/d3-color": ["@types/d3-color@3.1.3", "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], - "@types/d3-ease": ["@types/d3-ease@3.0.2", "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + "@types/d3-ease": ["@types/d3-ease@3.0.2", "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], - "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], - "@types/d3-path": ["@types/d3-path@3.1.1", "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + "@types/d3-path": ["@types/d3-path@3.1.1", "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], - "@types/d3-scale": ["@types/d3-scale@4.0.9", "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + "@types/d3-scale": ["@types/d3-scale@4.0.9", "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], - "@types/d3-shape": ["@types/d3-shape@3.1.8", "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + "@types/d3-shape": ["@types/d3-shape@3.1.8", "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], - "@types/d3-time": ["@types/d3-time@3.0.4", "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + "@types/d3-time": ["@types/d3-time@3.0.4", "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], - "@types/d3-timer": ["@types/d3-timer@3.0.2", "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/d3-timer": ["@types/d3-timer@3.0.2", "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], - "@types/debug": ["@types/debug@4.1.13", "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + "@types/debug": ["@types/debug@4.1.13", "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], - "@types/deep-eql": ["@types/deep-eql@4.0.2", "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], - "@types/esrecurse": ["@types/esrecurse@4.3.1", "https://registry.npmmirror.com/@types/esrecurse/-/esrecurse-4.3.1.tgz", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + "@types/esrecurse": ["@types/esrecurse@4.3.1", "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], - "@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/estree": ["@types/estree@1.0.8", "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "https://registry.npmmirror.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], - "@types/hast": ["@types/hast@3.0.5", "https://registry.npmmirror.com/@types/hast/-/hast-3.0.5.tgz", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + "@types/hast": ["@types/hast@3.0.5", "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], - "@types/json-schema": ["@types/json-schema@7.0.15", "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/mdast": ["@types/mdast@4.0.4", "https://registry.npmmirror.com/@types/mdast/-/mdast-4.0.4.tgz", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + "@types/mdast": ["@types/mdast@4.0.4", "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], - "@types/ms": ["@types/ms@2.1.0", "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/ms": ["@types/ms@2.1.0", "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@26.0.1", "https://registry.npmmirror.com/@types/node/-/node-26.0.1.tgz", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + "@types/node": ["@types/node@26.0.1", "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], - "@types/react": ["@types/react@19.2.17", "https://registry.npmmirror.com/@types/react/-/react-19.2.17.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + "@types/react": ["@types/react@19.2.17", "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], - "@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@types/trusted-types": ["@types/trusted-types@2.0.7", "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - "@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/unist": ["@types/unist@3.0.3", "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], - "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "https://registry.npmmirror.com/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], - "@types/ws": ["@types/ws@8.18.1", "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@types/ws": ["@types/ws@8.18.1", "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.62.0", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.62.0.tgz", {}, "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.62.0", "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", {}, "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.3", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.3", "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg=="], - "@vitest/browser": ["@vitest/browser@4.1.9", "https://registry.npmmirror.com/@vitest/browser/-/browser-4.1.9.tgz", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.9" } }, "sha512-j1BKtWmPcqpMhmx/L9EPLgAJpCb0zKfwoWLmqBbxaogCXHjOwHFSEoHCBfnGtx93xKQwilZ26m+UOsHqHMkRNg=="], + "@vitest/browser": ["@vitest/browser@4.1.9", "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.9.tgz", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.9" } }, "sha512-j1BKtWmPcqpMhmx/L9EPLgAJpCb0zKfwoWLmqBbxaogCXHjOwHFSEoHCBfnGtx93xKQwilZ26m+UOsHqHMkRNg=="], - "@vitest/browser-preview": ["@vitest/browser-preview@4.1.9", "https://registry.npmmirror.com/@vitest/browser-preview/-/browser-preview-4.1.9.tgz", { "dependencies": { "@testing-library/dom": "^10.4.1", "@testing-library/user-event": "^14.6.1", "@vitest/browser": "4.1.9" }, "peerDependencies": { "vitest": "4.1.9" } }, "sha512-a4/OrkMDb/WUnE4OOB/4FJbK3rYVO7YykqtUgcTKG4p2a0R3XcjPVu7SLRHFBs2+NIYhv5yxp1Lz3dbdGBjIow=="], + "@vitest/browser-preview": ["@vitest/browser-preview@4.1.9", "https://registry.npmjs.org/@vitest/browser-preview/-/browser-preview-4.1.9.tgz", { "dependencies": { "@testing-library/dom": "^10.4.1", "@testing-library/user-event": "^14.6.1", "@vitest/browser": "4.1.9" }, "peerDependencies": { "vitest": "4.1.9" } }, "sha512-a4/OrkMDb/WUnE4OOB/4FJbK3rYVO7YykqtUgcTKG4p2a0R3XcjPVu7SLRHFBs2+NIYhv5yxp1Lz3dbdGBjIow=="], - "@vitest/expect": ["@vitest/expect@4.1.9", "https://registry.npmmirror.com/@vitest/expect/-/expect-4.1.9.tgz", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + "@vitest/expect": ["@vitest/expect@4.1.9", "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - "@vitest/mocker": ["@vitest/mocker@4.1.9", "https://registry.npmmirror.com/@vitest/mocker/-/mocker-4.1.9.tgz", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + "@vitest/mocker": ["@vitest/mocker@4.1.9", "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - "@vitest/runner": ["@vitest/runner@4.1.9", "https://registry.npmmirror.com/@vitest/runner/-/runner-4.1.9.tgz", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + "@vitest/runner": ["@vitest/runner@4.1.9", "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - "@vitest/snapshot": ["@vitest/snapshot@4.1.9", "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-4.1.9.tgz", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.9", "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - "@vitest/spy": ["@vitest/spy@4.1.9", "https://registry.npmmirror.com/@vitest/spy/-/spy-4.1.9.tgz", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + "@vitest/spy": ["@vitest/spy@4.1.9", "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - "@vitest/utils": ["@vitest/utils@4.1.9", "https://registry.npmmirror.com/@vitest/utils/-/utils-4.1.9.tgz", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + "@vitest/utils": ["@vitest/utils@4.1.9", "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], - "@voidzero-dev/vite-plus-core": ["@voidzero-dev/vite-plus-core@0.2.1", "https://registry.npmmirror.com/@voidzero-dev/vite-plus-core/-/vite-plus-core-0.2.1.tgz", { "dependencies": { "@oxc-project/runtime": "=0.136.0", "@oxc-project/types": "=0.136.0", "lightningcss": "^1.30.2", "postcss": "^8.5.6" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.3", "@tsdown/exe": "0.22.3", "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "publint": "^0.3.8", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "typescript": "^5.0.0 || ^6.0.0", "unplugin-unused": "^0.5.0", "unrun": "*", "yaml": "^2.4.2" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "publint", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "typescript", "unplugin-unused", "unrun", "yaml"] }, "sha512-iWdtOlLezgYcDqIzxZx1yOUhY93vUB+ob+mRYBNr7/3Hf80uRyTQbqVD1WtsYaANbzeUi81SQ1ZoUraXHO+u8A=="], + "@voidzero-dev/vite-plus-core": ["@voidzero-dev/vite-plus-core@0.2.1", "https://registry.npmjs.org/@voidzero-dev/vite-plus-core/-/vite-plus-core-0.2.1.tgz", { "dependencies": { "@oxc-project/runtime": "=0.136.0", "@oxc-project/types": "=0.136.0", "lightningcss": "^1.30.2", "postcss": "^8.5.6" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.3", "@tsdown/exe": "0.22.3", "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "publint": "^0.3.8", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "typescript": "^5.0.0 || ^6.0.0", "unplugin-unused": "^0.5.0", "unrun": "*", "yaml": "^2.4.2" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "publint", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "typescript", "unplugin-unused", "unrun", "yaml"] }, "sha512-iWdtOlLezgYcDqIzxZx1yOUhY93vUB+ob+mRYBNr7/3Hf80uRyTQbqVD1WtsYaANbzeUi81SQ1ZoUraXHO+u8A=="], - "@voidzero-dev/vite-plus-darwin-arm64": ["@voidzero-dev/vite-plus-darwin-arm64@0.2.1", "https://registry.npmmirror.com/@voidzero-dev/vite-plus-darwin-arm64/-/vite-plus-darwin-arm64-0.2.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-9AfN/5LKRks8gbTaHPiQHT0L4yboy2xB6x6vvCRWxQMWxPS6/ZJLf5kUIZeE7I1z33AEyLKKkDscsZZVMgMLgg=="], + "@voidzero-dev/vite-plus-darwin-arm64": ["@voidzero-dev/vite-plus-darwin-arm64@0.2.1", "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-arm64/-/vite-plus-darwin-arm64-0.2.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-9AfN/5LKRks8gbTaHPiQHT0L4yboy2xB6x6vvCRWxQMWxPS6/ZJLf5kUIZeE7I1z33AEyLKKkDscsZZVMgMLgg=="], - "@voidzero-dev/vite-plus-darwin-x64": ["@voidzero-dev/vite-plus-darwin-x64@0.2.1", "https://registry.npmmirror.com/@voidzero-dev/vite-plus-darwin-x64/-/vite-plus-darwin-x64-0.2.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-Q1vyimRbf4M82qIQSWRyr7NJaH9ag5G7vVEfGVVJlQHNprI+Q8zj2Phcs/PGf6QcyjcL8UclLznQTHU9NgnKZw=="], + "@voidzero-dev/vite-plus-darwin-x64": ["@voidzero-dev/vite-plus-darwin-x64@0.2.1", "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-x64/-/vite-plus-darwin-x64-0.2.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-Q1vyimRbf4M82qIQSWRyr7NJaH9ag5G7vVEfGVVJlQHNprI+Q8zj2Phcs/PGf6QcyjcL8UclLznQTHU9NgnKZw=="], - "@voidzero-dev/vite-plus-linux-arm64-gnu": ["@voidzero-dev/vite-plus-linux-arm64-gnu@0.2.1", "https://registry.npmmirror.com/@voidzero-dev/vite-plus-linux-arm64-gnu/-/vite-plus-linux-arm64-gnu-0.2.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-WHW3DziqedRfhJ2upq6kC4y/pmdQWYt322DVB7+4Xb4oOa/CT9GtnSrWIiXVJ4PSO42v54+YsSTKPH2HC5RbtA=="], + "@voidzero-dev/vite-plus-linux-arm64-gnu": ["@voidzero-dev/vite-plus-linux-arm64-gnu@0.2.1", "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-arm64-gnu/-/vite-plus-linux-arm64-gnu-0.2.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-WHW3DziqedRfhJ2upq6kC4y/pmdQWYt322DVB7+4Xb4oOa/CT9GtnSrWIiXVJ4PSO42v54+YsSTKPH2HC5RbtA=="], - "@voidzero-dev/vite-plus-linux-arm64-musl": ["@voidzero-dev/vite-plus-linux-arm64-musl@0.2.1", "https://registry.npmmirror.com/@voidzero-dev/vite-plus-linux-arm64-musl/-/vite-plus-linux-arm64-musl-0.2.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-vUY7hYycZW0qEevpl7ImzZJFnOEKRYCaCOX4TBW0vk6MJZ+zj/xW7e0LOggzJcz2wbYAgLDqp5h+b8wV9dguDA=="], + "@voidzero-dev/vite-plus-linux-arm64-musl": ["@voidzero-dev/vite-plus-linux-arm64-musl@0.2.1", "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-arm64-musl/-/vite-plus-linux-arm64-musl-0.2.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-vUY7hYycZW0qEevpl7ImzZJFnOEKRYCaCOX4TBW0vk6MJZ+zj/xW7e0LOggzJcz2wbYAgLDqp5h+b8wV9dguDA=="], - "@voidzero-dev/vite-plus-linux-x64-gnu": ["@voidzero-dev/vite-plus-linux-x64-gnu@0.2.1", "https://registry.npmmirror.com/@voidzero-dev/vite-plus-linux-x64-gnu/-/vite-plus-linux-x64-gnu-0.2.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-tFxpToEaykBGxMQHp8M/qmr1yruRRED+c9gA1h9kmplqot04OxuqzRCWu/IiIvMJ0v3JFdOP3gqkyjXLLJhxIA=="], + "@voidzero-dev/vite-plus-linux-x64-gnu": ["@voidzero-dev/vite-plus-linux-x64-gnu@0.2.1", "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-x64-gnu/-/vite-plus-linux-x64-gnu-0.2.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-tFxpToEaykBGxMQHp8M/qmr1yruRRED+c9gA1h9kmplqot04OxuqzRCWu/IiIvMJ0v3JFdOP3gqkyjXLLJhxIA=="], - "@voidzero-dev/vite-plus-linux-x64-musl": ["@voidzero-dev/vite-plus-linux-x64-musl@0.2.1", "https://registry.npmmirror.com/@voidzero-dev/vite-plus-linux-x64-musl/-/vite-plus-linux-x64-musl-0.2.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-2scSS7wEbLO2758fqr1/bAULg7nLCFa5V8LO2b5w3g1CrTYdMTDt2WX1ghPesIi+70pYGydRbXo6iaaN43zfMg=="], + "@voidzero-dev/vite-plus-linux-x64-musl": ["@voidzero-dev/vite-plus-linux-x64-musl@0.2.1", "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-x64-musl/-/vite-plus-linux-x64-musl-0.2.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-2scSS7wEbLO2758fqr1/bAULg7nLCFa5V8LO2b5w3g1CrTYdMTDt2WX1ghPesIi+70pYGydRbXo6iaaN43zfMg=="], - "@voidzero-dev/vite-plus-win32-arm64-msvc": ["@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.1", "https://registry.npmmirror.com/@voidzero-dev/vite-plus-win32-arm64-msvc/-/vite-plus-win32-arm64-msvc-0.2.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-3+5FJYhi9SqBszjngI2LBmvoiqEwxJWyQ5UsOUtNz6/d+yDrDw+tOgHLl4OKIh5aVNZeIGXzxvP6h24kcEqIyg=="], + "@voidzero-dev/vite-plus-win32-arm64-msvc": ["@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.1", "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-arm64-msvc/-/vite-plus-win32-arm64-msvc-0.2.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-3+5FJYhi9SqBszjngI2LBmvoiqEwxJWyQ5UsOUtNz6/d+yDrDw+tOgHLl4OKIh5aVNZeIGXzxvP6h24kcEqIyg=="], - "@voidzero-dev/vite-plus-win32-x64-msvc": ["@voidzero-dev/vite-plus-win32-x64-msvc@0.2.1", "https://registry.npmmirror.com/@voidzero-dev/vite-plus-win32-x64-msvc/-/vite-plus-win32-x64-msvc-0.2.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-5sOEwEoU5PW7ObmJ5VCakU09Oh14rYCoLQJkFqvOph6PK30lN5iqWGk0KigEyfcd7Zv+fZg9EmcERDol/3Xl9w=="], + "@voidzero-dev/vite-plus-win32-x64-msvc": ["@voidzero-dev/vite-plus-win32-x64-msvc@0.2.1", "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-x64-msvc/-/vite-plus-win32-x64-msvc-0.2.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-5sOEwEoU5PW7ObmJ5VCakU09Oh14rYCoLQJkFqvOph6PK30lN5iqWGk0KigEyfcd7Zv+fZg9EmcERDol/3Xl9w=="], - "@vue/compiler-core": ["@vue/compiler-core@3.5.18", "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.18.tgz", { "dependencies": { "@babel/parser": "^7.28.0", "@vue/shared": "3.5.18", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-3slwjQrrV1TO8MoXgy3aynDQ7lslj5UqDxuHnrzHtpON5CBinhWjJETciPngpin/T3OuW3tXUf86tEurusnztw=="], + "@vue/compiler-core": ["@vue/compiler-core@3.5.18", "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.18.tgz", { "dependencies": { "@babel/parser": "^7.28.0", "@vue/shared": "3.5.18", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-3slwjQrrV1TO8MoXgy3aynDQ7lslj5UqDxuHnrzHtpON5CBinhWjJETciPngpin/T3OuW3tXUf86tEurusnztw=="], - "@vue/compiler-dom": ["@vue/compiler-dom@3.5.18", "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.18.tgz", { "dependencies": { "@vue/compiler-core": "3.5.18", "@vue/shared": "3.5.18" } }, "sha512-RMbU6NTU70++B1JyVJbNbeFkK+A+Q7y9XKE2EM4NLGm2WFR8x9MbAtWxPPLdm0wUkuZv9trpwfSlL6tjdIa1+A=="], + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.18", "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.18.tgz", { "dependencies": { "@vue/compiler-core": "3.5.18", "@vue/shared": "3.5.18" } }, "sha512-RMbU6NTU70++B1JyVJbNbeFkK+A+Q7y9XKE2EM4NLGm2WFR8x9MbAtWxPPLdm0wUkuZv9trpwfSlL6tjdIa1+A=="], - "@vue/shared": ["@vue/shared@3.5.18", "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.18.tgz", {}, "sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA=="], + "@vue/shared": ["@vue/shared@3.5.18", "https://registry.npmjs.org/@vue/shared/-/shared-3.5.18.tgz", {}, "sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA=="], - "@xterm/addon-clipboard": ["@xterm/addon-clipboard@0.2.0", "https://registry.npmmirror.com/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg=="], + "@xterm/addon-clipboard": ["@xterm/addon-clipboard@0.2.0", "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg=="], - "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "https://registry.npmmirror.com/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], + "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], - "@xterm/addon-search": ["@xterm/addon-search@0.16.0", "https://registry.npmmirror.com/@xterm/addon-search/-/addon-search-0.16.0.tgz", {}, "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA=="], + "@xterm/addon-search": ["@xterm/addon-search@0.16.0", "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", {}, "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA=="], - "@xterm/addon-serialize": ["@xterm/addon-serialize@0.14.0", "https://registry.npmmirror.com/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz", {}, "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA=="], + "@xterm/addon-serialize": ["@xterm/addon-serialize@0.14.0", "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz", {}, "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA=="], - "@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.9.0", "https://registry.npmmirror.com/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz", {}, "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw=="], + "@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.9.0", "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz", {}, "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw=="], - "@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "https://registry.npmmirror.com/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="], + "@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="], - "@xterm/addon-webgl": ["@xterm/addon-webgl@0.19.0", "https://registry.npmmirror.com/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", {}, "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="], + "@xterm/addon-webgl": ["@xterm/addon-webgl@0.19.0", "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", {}, "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="], - "@xterm/xterm": ["@xterm/xterm@6.0.0", "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "@xterm/xterm": ["@xterm/xterm@6.0.0", "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], - "acorn": ["acorn@8.17.0", "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + "acorn": ["acorn@8.17.0", "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], - "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "https://registry.npmmirror.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], + "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], - "acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "agent-install": ["agent-install@0.0.5", "https://registry.npmmirror.com/agent-install/-/agent-install-0.0.5.tgz", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ=="], + "agent-install": ["agent-install@0.0.5", "https://registry.npmjs.org/agent-install/-/agent-install-0.0.5.tgz", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ=="], - "ajv": ["ajv@8.17.1", "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + "ajv": ["ajv@8.17.1", "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], - "ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "argparse": ["argparse@2.0.1", "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "aria-hidden": ["aria-hidden@1.2.6", "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-hidden": ["aria-hidden@1.2.6", "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], - "aria-query": ["aria-query@5.3.0", "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.0.tgz", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "aria-query": ["aria-query@5.3.0", "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], - "assertion-error": ["assertion-error@2.0.1", "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "assertion-error": ["assertion-error@2.0.1", "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "astring": ["astring@1.9.0", "https://registry.npmmirror.com/astring/-/astring-1.9.0.tgz", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], + "astring": ["astring@1.9.0", "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], - "async": ["async@3.2.6", "https://registry.npmmirror.com/async/-/async-3.2.6.tgz", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "async": ["async@3.2.6", "https://registry.npmjs.org/async/-/async-3.2.6.tgz", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], - "atomically": ["atomically@2.1.1", "https://registry.npmmirror.com/atomically/-/atomically-2.1.1.tgz", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ=="], + "atomically": ["atomically@2.1.1", "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ=="], - "balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "balanced-match": ["balanced-match@4.0.4", "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "bippy": ["bippy@0.5.41", "https://registry.npmmirror.com/bippy/-/bippy-0.5.41.tgz", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg=="], + "bippy": ["bippy@0.5.41", "https://registry.npmjs.org/bippy/-/bippy-0.5.41.tgz", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg=="], - "brace-expansion": ["brace-expansion@5.0.6", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "brace-expansion": ["brace-expansion@5.0.6", "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], - "braces": ["braces@3.0.3", "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "braces": ["braces@3.0.3", "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "browserslist": ["browserslist@4.25.1", "https://registry.npmmirror.com/browserslist/-/browserslist-4.25.1.tgz", { "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw=="], + "browserslist": ["browserslist@4.25.1", "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", { "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw=="], - "buffer-image-size": ["buffer-image-size@0.6.4", "https://registry.npmmirror.com/buffer-image-size/-/buffer-image-size-0.6.4.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], - "bun-types": ["bun-types@1.3.14", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.14.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bun-types": ["bun-types@1.3.14", "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001727", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="], + "caniuse-lite": ["caniuse-lite@1.0.30001727", "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="], - "ccount": ["ccount@2.0.1", "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "ccount": ["ccount@2.0.1", "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chai": ["chai@6.2.2", "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chai": ["chai@6.2.2", "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - "chalk": ["chalk@4.1.1", "https://registry.npmmirror.com/chalk/-/chalk-4.1.1.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg=="], + "chalk": ["chalk@4.1.1", "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg=="], - "character-entities": ["character-entities@2.0.2", "https://registry.npmmirror.com/character-entities/-/character-entities-2.0.2.tgz", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + "character-entities": ["character-entities@2.0.2", "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], - "character-entities-html4": ["character-entities-html4@2.1.0", "https://registry.npmmirror.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + "character-entities-html4": ["character-entities-html4@2.1.0", "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], - "character-entities-legacy": ["character-entities-legacy@3.0.0", "https://registry.npmmirror.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + "character-entities-legacy": ["character-entities-legacy@3.0.0", "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - "character-reference-invalid": ["character-reference-invalid@2.0.1", "https://registry.npmmirror.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "character-reference-invalid": ["character-reference-invalid@2.0.1", "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "https://registry.npmmirror.com/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], + "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], - "class-variance-authority": ["class-variance-authority@0.7.1", "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], - "classnames": ["classnames@2.5.1", "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + "classnames": ["classnames@2.5.1", "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], - "cli-cursor": ["cli-cursor@5.0.0", "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-5.0.0.tgz", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + "cli-cursor": ["cli-cursor@5.0.0", "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], - "cli-spinners": ["cli-spinners@3.4.0", "https://registry.npmmirror.com/cli-spinners/-/cli-spinners-3.4.0.tgz", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], + "cli-spinners": ["cli-spinners@3.4.0", "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], - "cliui": ["cliui@9.0.1", "https://registry.npmmirror.com/cliui/-/cliui-9.0.1.tgz", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + "cliui": ["cliui@9.0.1", "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], - "clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "clsx": ["clsx@2.1.1", "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "cm6-theme-basic-light": ["cm6-theme-basic-light@0.2.0", "https://registry.npmmirror.com/cm6-theme-basic-light/-/cm6-theme-basic-light-0.2.0.tgz", { "peerDependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-1prg2gv44sYfpHscP26uLT/ePrh0mlmVwMSoSd3zYKQ92Ab3jPRLzyCnpyOCQLJbK+YdNs4HvMRqMNYdy4pMhA=="], + "cm6-theme-basic-light": ["cm6-theme-basic-light@0.2.0", "https://registry.npmjs.org/cm6-theme-basic-light/-/cm6-theme-basic-light-0.2.0.tgz", { "peerDependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-1prg2gv44sYfpHscP26uLT/ePrh0mlmVwMSoSd3zYKQ92Ab3jPRLzyCnpyOCQLJbK+YdNs4HvMRqMNYdy4pMhA=="], - "code-inspector-plugin": ["code-inspector-plugin@1.6.2", "https://registry.npmmirror.com/code-inspector-plugin/-/code-inspector-plugin-1.6.2.tgz", { "dependencies": { "@code-inspector/core": "1.6.2", "@code-inspector/esbuild": "1.6.2", "@code-inspector/mako": "1.6.2", "@code-inspector/turbopack": "1.6.2", "@code-inspector/vite": "1.6.2", "@code-inspector/webpack": "1.6.2", "chalk": "4.1.1" } }, "sha512-AuMiD3d+wiICwZ55JOUlotwcCoDLA307ZVWXu3B6X4qS4hNpdDIipNLPsBQxKxBmh71jnddARNeCj8EXrvvZQA=="], + "code-inspector-plugin": ["code-inspector-plugin@1.6.2", "https://registry.npmjs.org/code-inspector-plugin/-/code-inspector-plugin-1.6.2.tgz", { "dependencies": { "@code-inspector/core": "1.6.2", "@code-inspector/esbuild": "1.6.2", "@code-inspector/mako": "1.6.2", "@code-inspector/turbopack": "1.6.2", "@code-inspector/vite": "1.6.2", "@code-inspector/webpack": "1.6.2", "chalk": "4.1.1" } }, "sha512-AuMiD3d+wiICwZ55JOUlotwcCoDLA307ZVWXu3B6X4qS4hNpdDIipNLPsBQxKxBmh71jnddARNeCj8EXrvvZQA=="], - "codemirror": ["codemirror@6.0.2", "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="], + "codemirror": ["codemirror@6.0.2", "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="], - "color-convert": ["color-convert@2.0.1", "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "color-convert": ["color-convert@2.0.1", "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - "color-name": ["color-name@1.1.4", "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "color-name": ["color-name@1.1.4", "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "commander": ["commander@14.0.3", "https://registry.npmmirror.com/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "commander": ["commander@14.0.3", "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - "compute-scroll-into-view": ["compute-scroll-into-view@2.0.4", "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-2.0.4.tgz", {}, "sha512-y/ZA3BGnxoM/QHHQ2Uy49CLtnWPbt4tTPpEEZiEmmiWBFKjej7nEyH8Ryz54jH0MLXflUYA3Er2zUxPSJu5R+g=="], + "compute-scroll-into-view": ["compute-scroll-into-view@2.0.4", "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-2.0.4.tgz", {}, "sha512-y/ZA3BGnxoM/QHHQ2Uy49CLtnWPbt4tTPpEEZiEmmiWBFKjej7nEyH8Ryz54jH0MLXflUYA3Er2zUxPSJu5R+g=="], - "concurrently": ["concurrently@10.0.3", "https://registry.npmmirror.com/concurrently/-/concurrently-10.0.3.tgz", { "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", "shell-quote": "1.8.4", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" }, "bin": { "conc": "dist/bin/index.js", "concurrently": "dist/bin/index.js" } }, "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA=="], + "concurrently": ["concurrently@10.0.3", "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", { "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", "shell-quote": "1.8.4", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" }, "bin": { "conc": "dist/bin/index.js", "concurrently": "dist/bin/index.js" } }, "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA=="], - "conf": ["conf@15.1.0", "https://registry.npmmirror.com/conf/-/conf-15.1.0.tgz", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="], + "conf": ["conf@15.1.0", "https://registry.npmjs.org/conf/-/conf-15.1.0.tgz", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="], - "confbox": ["confbox@0.2.4", "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + "confbox": ["confbox@0.2.4", "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], - "convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "crelt": ["crelt@1.0.7", "https://registry.npmmirror.com/crelt/-/crelt-1.0.7.tgz", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="], + "crelt": ["crelt@1.0.7", "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="], - "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "csstype": ["csstype@3.2.3", "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "d3-array": ["d3-array@3.2.4", "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + "d3-array": ["d3-array@3.2.4", "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], - "d3-color": ["d3-color@3.1.0", "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + "d3-color": ["d3-color@3.1.0", "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], - "d3-ease": ["d3-ease@3.0.1", "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + "d3-ease": ["d3-ease@3.0.1", "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], - "d3-format": ["d3-format@3.1.2", "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + "d3-format": ["d3-format@3.1.2", "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], - "d3-interpolate": ["d3-interpolate@3.0.1", "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + "d3-interpolate": ["d3-interpolate@3.0.1", "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], - "d3-path": ["d3-path@3.1.0", "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + "d3-path": ["d3-path@3.1.0", "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], - "d3-scale": ["d3-scale@4.0.2", "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + "d3-scale": ["d3-scale@4.0.2", "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], - "d3-shape": ["d3-shape@3.2.0", "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + "d3-shape": ["d3-shape@3.2.0", "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], - "d3-time": ["d3-time@3.1.0", "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + "d3-time": ["d3-time@3.1.0", "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], - "d3-time-format": ["d3-time-format@4.1.0", "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + "d3-time-format": ["d3-time-format@4.1.0", "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], - "d3-timer": ["d3-timer@3.0.1", "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + "d3-timer": ["d3-timer@3.0.1", "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], - "date-fns": ["date-fns@4.4.0", "https://registry.npmmirror.com/date-fns/-/date-fns-4.4.0.tgz", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], + "date-fns": ["date-fns@4.4.0", "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], - "debounce-fn": ["debounce-fn@6.0.0", "https://registry.npmmirror.com/debounce-fn/-/debounce-fn-6.0.0.tgz", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="], + "debounce-fn": ["debounce-fn@6.0.0", "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="], - "debug": ["debug@4.4.1", "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + "debug": ["debug@4.4.1", "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], - "decimal.js-light": ["decimal.js-light@2.5.1", "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], - "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], - "deep-is": ["deep-is@0.1.4", "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "deep-is": ["deep-is@0.1.4", "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], - "dequal": ["dequal@2.0.3", "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "dequal": ["dequal@2.0.3", "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], - "deslop-js": ["deslop-js@0.5.8", "https://registry.npmmirror.com/deslop-js/-/deslop-js-0.5.8.tgz", { "dependencies": { "@oxc-project/types": "^0.132.0", "fast-glob": "^3.3.3", "minimatch": "^10.2.5", "oxc-parser": "^0.132.0", "oxc-resolver": "^11.19.1", "typescript": "^6.0.3" } }, "sha512-Vq9D2x4dAIW24zcH55DTrl3/vi13UNKfXgw0yj7ULTssZ6KOdw/oyBHtlvE94KFC9yYEhgFTrGjaqqZKvV9pwA=="], + "deslop-js": ["deslop-js@0.5.8", "https://registry.npmjs.org/deslop-js/-/deslop-js-0.5.8.tgz", { "dependencies": { "@oxc-project/types": "^0.132.0", "fast-glob": "^3.3.3", "minimatch": "^10.2.5", "oxc-parser": "^0.132.0", "oxc-resolver": "^11.19.1", "typescript": "^6.0.3" } }, "sha512-Vq9D2x4dAIW24zcH55DTrl3/vi13UNKfXgw0yj7ULTssZ6KOdw/oyBHtlvE94KFC9yYEhgFTrGjaqqZKvV9pwA=="], - "detect-libc": ["detect-libc@2.0.4", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.0.4.tgz", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], + "detect-libc": ["detect-libc@2.0.4", "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], - "detect-node-es": ["detect-node-es@1.1.0", "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "detect-node-es": ["detect-node-es@1.1.0", "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], - "devlop": ["devlop@1.1.0", "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "devlop": ["devlop@1.1.0", "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "diff": ["diff@8.0.2", "https://registry.npmmirror.com/diff/-/diff-8.0.2.tgz", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "diff": ["diff@8.0.2", "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], - "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], - "dompurify": ["dompurify@3.4.11", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.11.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="], + "dompurify": ["dompurify@3.4.11", "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="], - "dot-prop": ["dot-prop@10.1.0", "https://registry.npmmirror.com/dot-prop/-/dot-prop-10.1.0.tgz", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="], + "dot-prop": ["dot-prop@10.1.0", "https://registry.npmjs.org/dot-prop/-/dot-prop-10.1.0.tgz", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="], - "dotenv": ["dotenv@16.6.1", "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "dotenv": ["dotenv@16.6.1", "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], - "downshift": ["downshift@7.6.2", "https://registry.npmmirror.com/downshift/-/downshift-7.6.2.tgz", { "dependencies": { "@babel/runtime": "^7.14.8", "compute-scroll-into-view": "^2.0.4", "prop-types": "^15.7.2", "react-is": "^17.0.2", "tslib": "^2.3.0" }, "peerDependencies": { "react": ">=16.12.0" } }, "sha512-iOv+E1Hyt3JDdL9yYcOgW7nZ7GQ2Uz6YbggwXvKUSleetYhU2nXD482Rz6CzvM4lvI1At34BYruKAL4swRGxaA=="], + "downshift": ["downshift@7.6.2", "https://registry.npmjs.org/downshift/-/downshift-7.6.2.tgz", { "dependencies": { "@babel/runtime": "^7.14.8", "compute-scroll-into-view": "^2.0.4", "prop-types": "^15.7.2", "react-is": "^17.0.2", "tslib": "^2.3.0" }, "peerDependencies": { "react": ">=16.12.0" } }, "sha512-iOv+E1Hyt3JDdL9yYcOgW7nZ7GQ2Uz6YbggwXvKUSleetYhU2nXD482Rz6CzvM4lvI1At34BYruKAL4swRGxaA=="], - "effect": ["effect@3.22.0", "https://registry.npmmirror.com/effect/-/effect-3.22.0.tgz", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-jhYFe0zTlIRqYFrKTS+6luhmS/Tm0f+JLo0K9KUxvtFab1SUGEszQi2ehOP6QzAZvy831lDmTwwzvVDZSPNz3g=="], + "effect": ["effect@3.22.0", "https://registry.npmjs.org/effect/-/effect-3.22.0.tgz", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-jhYFe0zTlIRqYFrKTS+6luhmS/Tm0f+JLo0K9KUxvtFab1SUGEszQi2ehOP6QzAZvy831lDmTwwzvVDZSPNz3g=="], - "electron-to-chromium": ["electron-to-chromium@1.5.191", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.191.tgz", {}, "sha512-xcwe9ELcuxYLUFqZZxL19Z6HVKcvNkIwhbHUz7L3us6u12yR+7uY89dSl570f/IqNthx8dAw3tojG7i4Ni4tDA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.191", "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.191.tgz", {}, "sha512-xcwe9ELcuxYLUFqZZxL19Z6HVKcvNkIwhbHUz7L3us6u12yR+7uY89dSl570f/IqNthx8dAw3tojG7i4Ni4tDA=="], - "embla-carousel": ["embla-carousel@8.6.0", "https://registry.npmmirror.com/embla-carousel/-/embla-carousel-8.6.0.tgz", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], + "embla-carousel": ["embla-carousel@8.6.0", "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], - "embla-carousel-react": ["embla-carousel-react@8.6.0", "https://registry.npmmirror.com/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="], + "embla-carousel-react": ["embla-carousel-react@8.6.0", "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="], - "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "https://registry.npmmirror.com/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], + "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], - "emoji-regex": ["emoji-regex@10.6.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "emoji-regex": ["emoji-regex@10.6.0", "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - "enhanced-resolve": ["enhanced-resolve@5.21.6", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], + "enhanced-resolve": ["enhanced-resolve@5.21.6", "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], - "entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "entities": ["entities@7.0.1", "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "env-paths": ["env-paths@3.0.0", "https://registry.npmmirror.com/env-paths/-/env-paths-3.0.0.tgz", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + "env-paths": ["env-paths@3.0.0", "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], - "es-module-lexer": ["es-module-lexer@2.1.0", "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.1.0.tgz", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], + "es-module-lexer": ["es-module-lexer@2.1.0", "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], - "es-toolkit": ["es-toolkit@1.48.1", "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.48.1.tgz", {}, "sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ=="], + "es-toolkit": ["es-toolkit@1.48.1", "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.48.1.tgz", {}, "sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ=="], - "esbuild": ["esbuild@0.25.8", "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.8.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="], + "esbuild": ["esbuild@0.25.8", "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="], - "escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escalade": ["escalade@3.2.0", "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "escape-string-regexp": ["escape-string-regexp@5.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "escape-string-regexp": ["escape-string-regexp@5.0.0", "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "eslint": ["eslint@10.5.0", "https://registry.npmmirror.com/eslint/-/eslint-10.5.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ=="], + "eslint": ["eslint@10.5.0", "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ=="], - "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "https://registry.npmmirror.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], - "eslint-scope": ["eslint-scope@9.1.2", "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-9.1.2.tgz", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + "eslint-scope": ["eslint-scope@9.1.2", "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], - "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "espree": ["espree@11.2.0", "https://registry.npmmirror.com/espree/-/espree-11.2.0.tgz", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + "espree": ["espree@11.2.0", "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], - "esquery": ["esquery@1.7.0", "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + "esquery": ["esquery@1.7.0", "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], - "esrecurse": ["esrecurse@4.3.0", "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + "esrecurse": ["esrecurse@4.3.0", "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], - "estraverse": ["estraverse@5.3.0", "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "estraverse": ["estraverse@5.3.0", "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "https://registry.npmmirror.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], - "estree-util-visit": ["estree-util-visit@2.0.0", "https://registry.npmmirror.com/estree-util-visit/-/estree-util-visit-2.0.0.tgz", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" } }, "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww=="], + "estree-util-visit": ["estree-util-visit@2.0.0", "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" } }, "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww=="], - "estree-walker": ["estree-walker@3.0.3", "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "estree-walker": ["estree-walker@3.0.3", "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "esutils": ["esutils@2.0.3", "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "esutils": ["esutils@2.0.3", "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "eventemitter3": ["eventemitter3@5.0.4", "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "eventemitter3": ["eventemitter3@5.0.4", "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - "expect-type": ["expect-type@1.4.0", "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "expect-type": ["expect-type@1.4.0", "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], - "fast-check": ["fast-check@3.23.2", "https://registry.npmmirror.com/fast-check/-/fast-check-3.23.2.tgz", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "fast-check": ["fast-check@3.23.2", "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], - "fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-glob": ["fast-glob@3.3.3", "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + "fast-glob": ["fast-glob@3.3.3", "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - "fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fast-uri": ["fast-uri@3.0.6", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.0.6.tgz", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="], + "fast-uri": ["fast-uri@3.0.6", "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="], - "fastq": ["fastq@1.20.1", "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fastq": ["fastq@1.20.1", "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], - "fault": ["fault@2.0.1", "https://registry.npmmirror.com/fault/-/fault-2.0.1.tgz", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="], + "fault": ["fault@2.0.1", "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="], - "fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fdir": ["fdir@6.5.0", "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "file-entry-cache": ["file-entry-cache@8.0.0", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], - "fill-range": ["fill-range@7.1.1", "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "fill-range": ["fill-range@7.1.1", "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - "find-up": ["find-up@5.0.0", "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "find-up": ["find-up@5.0.0", "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], - "flat-cache": ["flat-cache@4.0.1", "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + "flat-cache": ["flat-cache@4.0.1", "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], - "flatted": ["flatted@3.4.2", "https://registry.npmmirror.com/flatted/-/flatted-3.4.2.tgz", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + "flatted": ["flatted@3.4.2", "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], - "format": ["format@0.2.2", "https://registry.npmmirror.com/format/-/format-0.2.2.tgz", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], + "format": ["format@0.2.2", "https://registry.npmjs.org/format/-/format-0.2.2.tgz", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], - "framer-motion": ["framer-motion@12.43.0", "https://registry.npmmirror.com/framer-motion/-/framer-motion-12.43.0.tgz", { "dependencies": { "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g=="], + "framer-motion": ["framer-motion@12.43.0", "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", { "dependencies": { "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g=="], - "fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fsevents": ["fsevents@2.3.3", "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "gensync": ["gensync@1.0.0-beta.2", "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "gensync": ["gensync@1.0.0-beta.2", "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - "get-caller-file": ["get-caller-file@2.0.5", "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-caller-file": ["get-caller-file@2.0.5", "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - "get-east-asian-width": ["get-east-asian-width@1.6.0", "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], - "get-nonce": ["get-nonce@1.0.1", "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "get-nonce": ["get-nonce@1.0.1", "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], - "get-tsconfig": ["get-tsconfig@4.10.1", "https://registry.npmmirror.com/get-tsconfig/-/get-tsconfig-4.10.1.tgz", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="], + "get-tsconfig": ["get-tsconfig@4.10.1", "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="], - "glob-parent": ["glob-parent@5.1.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "glob-parent": ["glob-parent@5.1.2", "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "happy-dom": ["happy-dom@20.11.6", "https://registry.npmmirror.com/happy-dom/-/happy-dom-20.11.6.tgz", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg=="], + "happy-dom": ["happy-dom@20.11.6", "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.6.tgz", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg=="], - "has-flag": ["has-flag@4.0.0", "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-flag": ["has-flag@4.0.0", "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "hermes-estree": ["hermes-estree@0.25.1", "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.25.1.tgz", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + "hermes-estree": ["hermes-estree@0.25.1", "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], - "hermes-parser": ["hermes-parser@0.25.1", "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "hermes-parser": ["hermes-parser@0.25.1", "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], - "ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "ignore": ["ignore@7.0.5", "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "immer": ["immer@11.1.8", "https://registry.npmmirror.com/immer/-/immer-11.1.8.tgz", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="], + "immer": ["immer@11.1.8", "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="], - "import-in-the-middle": ["import-in-the-middle@3.2.0", "https://registry.npmmirror.com/import-in-the-middle/-/import-in-the-middle-3.2.0.tgz", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-vR2B6HKIhaBjcZr2bLpFiJ1VbzOlRQ7aby4/gw5WPIzToLjqpfWw3VJ4sk1uDchoOODEirvO2jyrSPtUSL5CrQ=="], + "import-in-the-middle": ["import-in-the-middle@3.2.0", "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.2.0.tgz", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-vR2B6HKIhaBjcZr2bLpFiJ1VbzOlRQ7aby4/gw5WPIzToLjqpfWw3VJ4sk1uDchoOODEirvO2jyrSPtUSL5CrQ=="], - "imurmurhash": ["imurmurhash@0.1.4", "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "imurmurhash": ["imurmurhash@0.1.4", "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - "input-otp": ["input-otp@1.4.2", "https://registry.npmmirror.com/input-otp/-/input-otp-1.4.2.tgz", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], + "input-otp": ["input-otp@1.4.2", "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], - "internmap": ["internmap@2.0.3", "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "internmap": ["internmap@2.0.3", "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], - "is-alphabetical": ["is-alphabetical@2.0.1", "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], - "is-alphanumerical": ["is-alphanumerical@2.0.1", "https://registry.npmmirror.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + "is-alphanumerical": ["is-alphanumerical@2.0.1", "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], - "is-decimal": ["is-decimal@2.0.1", "https://registry.npmmirror.com/is-decimal/-/is-decimal-2.0.1.tgz", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-decimal": ["is-decimal@2.0.1", "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], - "is-extglob": ["is-extglob@2.1.1", "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-extglob": ["is-extglob@2.1.1", "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - "is-glob": ["is-glob@4.0.3", "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-glob": ["is-glob@4.0.3", "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-hexadecimal": ["is-hexadecimal@2.0.1", "https://registry.npmmirror.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], - "is-interactive": ["is-interactive@2.0.0", "https://registry.npmmirror.com/is-interactive/-/is-interactive-2.0.0.tgz", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], + "is-interactive": ["is-interactive@2.0.0", "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], - "is-number": ["is-number@7.0.0", "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-number": ["is-number@7.0.0", "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - "is-unicode-supported": ["is-unicode-supported@2.1.0", "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "is-unicode-supported": ["is-unicode-supported@2.1.0", "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - "isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isexe": ["isexe@2.0.0", "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "isomorphic.js": ["isomorphic.js@0.2.5", "https://registry.npmmirror.com/isomorphic.js/-/isomorphic.js-0.2.5.tgz", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], + "isomorphic.js": ["isomorphic.js@0.2.5", "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], - "jiti": ["jiti@2.7.0", "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "jiti": ["jiti@2.7.0", "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "js-base64": ["js-base64@3.7.8", "https://registry.npmmirror.com/js-base64/-/js-base64-3.7.8.tgz", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], + "js-base64": ["js-base64@3.7.8", "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], - "js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "js-tokens": ["js-tokens@4.0.0", "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.3.0", "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.3.0.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "js-yaml": ["js-yaml@4.3.0", "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], - "jsesc": ["jsesc@3.1.0", "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "jsesc": ["jsesc@3.1.0", "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - "json-buffer": ["json-buffer@3.0.1", "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-buffer": ["json-buffer@3.0.1", "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - "json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "json-schema-typed": ["json-schema-typed@8.0.2", "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json-schema-typed": ["json-schema-typed@8.0.2", "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], - "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], - "json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "json5": ["json5@2.2.3", "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - "jsonc-parser": ["jsonc-parser@3.3.1", "https://registry.npmmirror.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], - "keyv": ["keyv@4.5.4", "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "keyv": ["keyv@4.5.4", "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - "kleur": ["kleur@3.0.3", "https://registry.npmmirror.com/kleur/-/kleur-3.0.3.tgz", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "kleur": ["kleur@3.0.3", "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], - "launch-ide": ["launch-ide@1.4.3", "https://registry.npmmirror.com/launch-ide/-/launch-ide-1.4.3.tgz", { "dependencies": { "chalk": "^4.1.1", "dotenv": "^16.1.4" } }, "sha512-v2xMAarJOFy51kuesYEIIx5r4WHvsV+VLMU49K24bdiRZGUpo1ZulO1DRrLozM5BMbXUfRfrUTM2PbBfYCeA4Q=="], + "launch-ide": ["launch-ide@1.4.3", "https://registry.npmjs.org/launch-ide/-/launch-ide-1.4.3.tgz", { "dependencies": { "chalk": "^4.1.1", "dotenv": "^16.1.4" } }, "sha512-v2xMAarJOFy51kuesYEIIx5r4WHvsV+VLMU49K24bdiRZGUpo1ZulO1DRrLozM5BMbXUfRfrUTM2PbBfYCeA4Q=="], - "levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "levn": ["levn@0.4.1", "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - "lexical": ["lexical@0.48.0", "https://registry.npmmirror.com/lexical/-/lexical-0.48.0.tgz", { "dependencies": { "@lexical/internal": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-KK4Tyr/cPsleoZ7XvhGRiRmcrZidSmoFUdIXK9nPubIifoC+80Dc5THyc4xtGKtsW24S1TsHzk5gmfBU+TxmEg=="], + "lexical": ["lexical@0.48.0", "https://registry.npmjs.org/lexical/-/lexical-0.48.0.tgz", { "dependencies": { "@lexical/internal": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-KK4Tyr/cPsleoZ7XvhGRiRmcrZidSmoFUdIXK9nPubIifoC+80Dc5THyc4xtGKtsW24S1TsHzk5gmfBU+TxmEg=="], - "lib0": ["lib0@0.2.117", "https://registry.npmmirror.com/lib0/-/lib0-0.2.117.tgz", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="], + "lib0": ["lib0@0.2.117", "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="], - "lightningcss": ["lightningcss@1.30.2", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.2.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], + "lightningcss": ["lightningcss@1.30.2", "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], "lithe": ["lithe@workspace:windows/tauri"], - "locate-path": ["locate-path@6.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "locate-path": ["locate-path@6.0.0", "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - "lodash.debounce": ["lodash.debounce@4.0.8", "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + "lodash.debounce": ["lodash.debounce@4.0.8", "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], - "log-symbols": ["log-symbols@7.0.1", "https://registry.npmmirror.com/log-symbols/-/log-symbols-7.0.1.tgz", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], + "log-symbols": ["log-symbols@7.0.1", "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], - "longest-streak": ["longest-streak@3.1.0", "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "longest-streak": ["longest-streak@3.1.0", "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], - "loose-envify": ["loose-envify@1.4.0", "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "loose-envify": ["loose-envify@1.4.0", "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "lru-cache": ["lru-cache@5.1.1", "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@5.1.1", "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lucide-react": ["lucide-react@0.468.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.468.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA=="], + "lucide-react": ["lucide-react@0.468.0", "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA=="], - "lz-string": ["lz-string@1.5.0", "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "lz-string": ["lz-string@1.5.0", "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], - "magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "magic-string": ["magic-string@0.30.21", "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "magicast": ["magicast@0.5.3", "https://registry.npmmirror.com/magicast/-/magicast-0.5.3.tgz", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], + "magicast": ["magicast@0.5.3", "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], - "make-cancellable-promise": ["make-cancellable-promise@2.0.0", "https://registry.npmmirror.com/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz", {}, "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw=="], + "make-cancellable-promise": ["make-cancellable-promise@2.0.0", "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz", {}, "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw=="], - "make-event-props": ["make-event-props@2.0.0", "https://registry.npmmirror.com/make-event-props/-/make-event-props-2.0.0.tgz", {}, "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw=="], + "make-event-props": ["make-event-props@2.0.0", "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz", {}, "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw=="], - "markdown-table": ["markdown-table@3.0.4", "https://registry.npmmirror.com/markdown-table/-/markdown-table-3.0.4.tgz", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "markdown-table": ["markdown-table@3.0.4", "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@14.0.0", "https://registry.npmmirror.com/marked/-/marked-14.0.0.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], + "marked": ["marked@14.0.0", "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], - "mdast-util-directive": ["mdast-util-directive@3.1.0", "https://registry.npmmirror.com/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q=="], + "mdast-util-directive": ["mdast-util-directive@3.1.0", "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q=="], - "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "https://registry.npmmirror.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], - "mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "https://registry.npmmirror.com/mdast-util-frontmatter/-/mdast-util-frontmatter-2.0.1.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="], + "mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "https://registry.npmjs.org/mdast-util-frontmatter/-/mdast-util-frontmatter-2.0.1.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="], - "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "https://registry.npmmirror.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], - "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "https://registry.npmmirror.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], - "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "https://registry.npmmirror.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], - "mdast-util-highlight-mark": ["mdast-util-highlight-mark@1.2.2", "https://registry.npmmirror.com/mdast-util-highlight-mark/-/mdast-util-highlight-mark-1.2.2.tgz", { "dependencies": { "micromark-extension-highlight-mark": "1.2.0" } }, "sha512-OYumVoytj+B9YgwzBhBcYUCLYHIPvJtAvwnMyKhUXbfUFuER5S+FDZyu9fadUxm2TCT5fRYK3jQXh2ioWAxrMw=="], + "mdast-util-highlight-mark": ["mdast-util-highlight-mark@1.2.2", "https://registry.npmjs.org/mdast-util-highlight-mark/-/mdast-util-highlight-mark-1.2.2.tgz", { "dependencies": { "micromark-extension-highlight-mark": "1.2.0" } }, "sha512-OYumVoytj+B9YgwzBhBcYUCLYHIPvJtAvwnMyKhUXbfUFuER5S+FDZyu9fadUxm2TCT5fRYK3jQXh2ioWAxrMw=="], - "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "https://registry.npmmirror.com/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], + "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], - "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "https://registry.npmmirror.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], - "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "https://registry.npmmirror.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], - "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "https://registry.npmmirror.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], - "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "https://registry.npmmirror.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], - "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "https://registry.npmmirror.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], - "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "https://registry.npmmirror.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], - "merge-refs": ["merge-refs@2.0.0", "https://registry.npmmirror.com/merge-refs/-/merge-refs-2.0.0.tgz", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg=="], + "merge-refs": ["merge-refs@2.0.0", "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg=="], - "merge2": ["merge2@1.4.1", "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "merge2": ["merge2@1.4.1", "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "meriyah": ["meriyah@6.1.4", "https://registry.npmmirror.com/meriyah/-/meriyah-6.1.4.tgz", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="], + "meriyah": ["meriyah@6.1.4", "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="], - "micromark": ["micromark@4.0.2", "https://registry.npmmirror.com/micromark/-/micromark-4.0.2.tgz", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + "micromark": ["micromark@4.0.2", "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], - "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "https://registry.npmmirror.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], - "micromark-extension-directive": ["micromark-extension-directive@3.0.2", "https://registry.npmmirror.com/micromark-extension-directive/-/micromark-extension-directive-3.0.2.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "parse-entities": "^4.0.0" } }, "sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA=="], + "micromark-extension-directive": ["micromark-extension-directive@3.0.2", "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-3.0.2.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "parse-entities": "^4.0.0" } }, "sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA=="], - "micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "https://registry.npmmirror.com/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="], + "micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "https://registry.npmjs.org/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="], - "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "https://registry.npmmirror.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], - "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "https://registry.npmmirror.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], - "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "https://registry.npmmirror.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], - "micromark-extension-highlight-mark": ["micromark-extension-highlight-mark@1.2.0", "https://registry.npmmirror.com/micromark-extension-highlight-mark/-/micromark-extension-highlight-mark-1.2.0.tgz", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "uvu": "^0.5.6" } }, "sha512-huGtbd/9kQsMk8u7nrVMaS5qH/47yDG6ZADggo5Owz5JoY8wdfQjfuy118/QiYNCvdFuFDbzT0A7K7Hp2cBsXA=="], + "micromark-extension-highlight-mark": ["micromark-extension-highlight-mark@1.2.0", "https://registry.npmjs.org/micromark-extension-highlight-mark/-/micromark-extension-highlight-mark-1.2.0.tgz", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "uvu": "^0.5.6" } }, "sha512-huGtbd/9kQsMk8u7nrVMaS5qH/47yDG6ZADggo5Owz5JoY8wdfQjfuy118/QiYNCvdFuFDbzT0A7K7Hp2cBsXA=="], - "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "https://registry.npmmirror.com/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="], + "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="], - "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "https://registry.npmmirror.com/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], + "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], - "micromark-extension-mdx-md": ["micromark-extension-mdx-md@2.0.0", "https://registry.npmmirror.com/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ=="], + "micromark-extension-mdx-md": ["micromark-extension-mdx-md@2.0.0", "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ=="], - "micromark-extension-mdxjs": ["micromark-extension-mdxjs@3.0.0", "https://registry.npmmirror.com/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", { "dependencies": { "acorn": "^8.0.0", "acorn-jsx": "^5.0.0", "micromark-extension-mdx-expression": "^3.0.0", "micromark-extension-mdx-jsx": "^3.0.0", "micromark-extension-mdx-md": "^2.0.0", "micromark-extension-mdxjs-esm": "^3.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ=="], + "micromark-extension-mdxjs": ["micromark-extension-mdxjs@3.0.0", "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", { "dependencies": { "acorn": "^8.0.0", "acorn-jsx": "^5.0.0", "micromark-extension-mdx-expression": "^3.0.0", "micromark-extension-mdx-jsx": "^3.0.0", "micromark-extension-mdx-md": "^2.0.0", "micromark-extension-mdxjs-esm": "^3.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ=="], - "micromark-extension-mdxjs-esm": ["micromark-extension-mdxjs-esm@3.0.0", "https://registry.npmmirror.com/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A=="], + "micromark-extension-mdxjs-esm": ["micromark-extension-mdxjs-esm@3.0.0", "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A=="], - "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "https://registry.npmmirror.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], - "micromark-factory-label": ["micromark-factory-label@2.0.1", "https://registry.npmmirror.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + "micromark-factory-label": ["micromark-factory-label@2.0.1", "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], - "micromark-factory-mdx-expression": ["micromark-factory-mdx-expression@2.0.3", "https://registry.npmmirror.com/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ=="], + "micromark-factory-mdx-expression": ["micromark-factory-mdx-expression@2.0.3", "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ=="], - "micromark-factory-space": ["micromark-factory-space@2.0.1", "https://registry.npmmirror.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + "micromark-factory-space": ["micromark-factory-space@2.0.1", "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], - "micromark-factory-title": ["micromark-factory-title@2.0.1", "https://registry.npmmirror.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + "micromark-factory-title": ["micromark-factory-title@2.0.1", "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], - "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "https://registry.npmmirror.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], - "micromark-util-character": ["micromark-util-character@2.1.1", "https://registry.npmmirror.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + "micromark-util-character": ["micromark-util-character@2.1.1", "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], - "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "https://registry.npmmirror.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], - "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "https://registry.npmmirror.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], - "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "https://registry.npmmirror.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], - "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "https://registry.npmmirror.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], - "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "https://registry.npmmirror.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], - "micromark-util-encode": ["micromark-util-encode@2.0.1", "https://registry.npmmirror.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + "micromark-util-encode": ["micromark-util-encode@2.0.1", "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], - "micromark-util-events-to-acorn": ["micromark-util-events-to-acorn@2.0.3", "https://registry.npmmirror.com/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg=="], + "micromark-util-events-to-acorn": ["micromark-util-events-to-acorn@2.0.3", "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg=="], - "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "https://registry.npmmirror.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], - "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "https://registry.npmmirror.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], - "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "https://registry.npmmirror.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], - "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "https://registry.npmmirror.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], - "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "https://registry.npmmirror.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], - "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "https://registry.npmmirror.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], - "micromark-util-types": ["micromark-util-types@2.0.2", "https://registry.npmmirror.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "micromark-util-types": ["micromark-util-types@2.0.2", "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], - "micromatch": ["micromatch@4.0.8", "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "micromatch": ["micromatch@4.0.8", "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mimic-function": ["mimic-function@5.0.1", "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "mimic-function": ["mimic-function@5.0.1", "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], - "minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "minimatch": ["minimatch@10.2.5", "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "module-details-from-path": ["module-details-from-path@1.0.4", "https://registry.npmmirror.com/module-details-from-path/-/module-details-from-path-1.0.4.tgz", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], + "module-details-from-path": ["module-details-from-path@1.0.4", "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], - "monaco-editor": ["monaco-editor@0.55.1", "https://registry.npmmirror.com/monaco-editor/-/monaco-editor-0.55.1.tgz", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="], + "monaco-editor": ["monaco-editor@0.55.1", "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="], - "monaco-vim": ["monaco-vim@0.4.4", "https://registry.npmmirror.com/monaco-vim/-/monaco-vim-0.4.4.tgz", { "peerDependencies": { "monaco-editor": "*" } }, "sha512-LNChAb//WEm/W+eyeHG/0+pdVEHotk2hLTN+M3sQZx5E8cAlSWSgqcxpcRuQnxDybSln7pfHF9i63HmbIQvrWw=="], + "monaco-vim": ["monaco-vim@0.4.4", "https://registry.npmjs.org/monaco-vim/-/monaco-vim-0.4.4.tgz", { "peerDependencies": { "monaco-editor": "*" } }, "sha512-LNChAb//WEm/W+eyeHG/0+pdVEHotk2hLTN+M3sQZx5E8cAlSWSgqcxpcRuQnxDybSln7pfHF9i63HmbIQvrWw=="], - "motion": ["motion@12.43.0", "https://registry.npmmirror.com/motion/-/motion-12.43.0.tgz", { "dependencies": { "framer-motion": "^12.43.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ=="], + "motion": ["motion@12.43.0", "https://registry.npmjs.org/motion/-/motion-12.43.0.tgz", { "dependencies": { "framer-motion": "^12.43.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ=="], - "motion-dom": ["motion-dom@12.43.0", "https://registry.npmmirror.com/motion-dom/-/motion-dom-12.43.0.tgz", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="], + "motion-dom": ["motion-dom@12.43.0", "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="], - "motion-utils": ["motion-utils@12.39.0", "https://registry.npmmirror.com/motion-utils/-/motion-utils-12.39.0.tgz", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + "motion-utils": ["motion-utils@12.39.0", "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], - "mri": ["mri@1.2.0", "https://registry.npmmirror.com/mri/-/mri-1.2.0.tgz", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + "mri": ["mri@1.2.0", "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], - "mrmime": ["mrmime@2.0.1", "https://registry.npmmirror.com/mrmime/-/mrmime-2.0.1.tgz", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + "mrmime": ["mrmime@2.0.1", "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], - "ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "ms": ["ms@2.1.3", "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nan": ["nan@2.25.0", "https://registry.npmmirror.com/nan/-/nan-2.25.0.tgz", {}, "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g=="], + "nan": ["nan@2.25.0", "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", {}, "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g=="], - "nanoid": ["nanoid@5.1.16", "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.16.tgz", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="], + "nanoid": ["nanoid@5.1.16", "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="], - "natural-compare": ["natural-compare@1.4.0", "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "natural-compare": ["natural-compare@1.4.0", "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "node-addon-api": ["node-addon-api@8.6.0", "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-8.6.0.tgz", {}, "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q=="], + "node-addon-api": ["node-addon-api@8.6.0", "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz", {}, "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q=="], - "node-gyp-build": ["node-gyp-build@4.8.4", "https://registry.npmmirror.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "node-gyp-build": ["node-gyp-build@4.8.4", "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], - "node-releases": ["node-releases@2.0.19", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.19.tgz", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], + "node-releases": ["node-releases@2.0.19", "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], - "object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "object-assign": ["object-assign@4.1.1", "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - "obug": ["obug@2.1.1", "https://registry.npmmirror.com/obug/-/obug-2.1.1.tgz", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "obug": ["obug@2.1.1", "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], - "onetime": ["onetime@7.0.0", "https://registry.npmmirror.com/onetime/-/onetime-7.0.0.tgz", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "onetime": ["onetime@7.0.0", "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - "optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "optionator": ["optionator@0.9.4", "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - "ora": ["ora@9.4.1", "https://registry.npmmirror.com/ora/-/ora-9.4.1.tgz", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw=="], + "ora": ["ora@9.4.1", "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw=="], - "oxc-parser": ["oxc-parser@0.132.0", "https://registry.npmmirror.com/oxc-parser/-/oxc-parser-0.132.0.tgz", { "dependencies": { "@oxc-project/types": "^0.132.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.132.0", "@oxc-parser/binding-android-arm64": "0.132.0", "@oxc-parser/binding-darwin-arm64": "0.132.0", "@oxc-parser/binding-darwin-x64": "0.132.0", "@oxc-parser/binding-freebsd-x64": "0.132.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.132.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.132.0", "@oxc-parser/binding-linux-arm64-gnu": "0.132.0", "@oxc-parser/binding-linux-arm64-musl": "0.132.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.132.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.132.0", "@oxc-parser/binding-linux-riscv64-musl": "0.132.0", "@oxc-parser/binding-linux-s390x-gnu": "0.132.0", "@oxc-parser/binding-linux-x64-gnu": "0.132.0", "@oxc-parser/binding-linux-x64-musl": "0.132.0", "@oxc-parser/binding-openharmony-arm64": "0.132.0", "@oxc-parser/binding-wasm32-wasi": "0.132.0", "@oxc-parser/binding-win32-arm64-msvc": "0.132.0", "@oxc-parser/binding-win32-ia32-msvc": "0.132.0", "@oxc-parser/binding-win32-x64-msvc": "0.132.0" } }, "sha512-+0LAPHaqtfQlvWdpaAa09SmOaZZgP8C552xosEkGJ4+ruEwP1Vgx+sqBgcBCNfR6KDCmagGOZTde8wmAvcI/Hg=="], + "oxc-parser": ["oxc-parser@0.132.0", "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.132.0.tgz", { "dependencies": { "@oxc-project/types": "^0.132.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.132.0", "@oxc-parser/binding-android-arm64": "0.132.0", "@oxc-parser/binding-darwin-arm64": "0.132.0", "@oxc-parser/binding-darwin-x64": "0.132.0", "@oxc-parser/binding-freebsd-x64": "0.132.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.132.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.132.0", "@oxc-parser/binding-linux-arm64-gnu": "0.132.0", "@oxc-parser/binding-linux-arm64-musl": "0.132.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.132.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.132.0", "@oxc-parser/binding-linux-riscv64-musl": "0.132.0", "@oxc-parser/binding-linux-s390x-gnu": "0.132.0", "@oxc-parser/binding-linux-x64-gnu": "0.132.0", "@oxc-parser/binding-linux-x64-musl": "0.132.0", "@oxc-parser/binding-openharmony-arm64": "0.132.0", "@oxc-parser/binding-wasm32-wasi": "0.132.0", "@oxc-parser/binding-win32-arm64-msvc": "0.132.0", "@oxc-parser/binding-win32-ia32-msvc": "0.132.0", "@oxc-parser/binding-win32-x64-msvc": "0.132.0" } }, "sha512-+0LAPHaqtfQlvWdpaAa09SmOaZZgP8C552xosEkGJ4+ruEwP1Vgx+sqBgcBCNfR6KDCmagGOZTde8wmAvcI/Hg=="], - "oxc-resolver": ["oxc-resolver@11.21.3", "https://registry.npmmirror.com/oxc-resolver/-/oxc-resolver-11.21.3.tgz", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.21.3", "@oxc-resolver/binding-android-arm64": "11.21.3", "@oxc-resolver/binding-darwin-arm64": "11.21.3", "@oxc-resolver/binding-darwin-x64": "11.21.3", "@oxc-resolver/binding-freebsd-x64": "11.21.3", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.3", "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.3", "@oxc-resolver/binding-linux-arm64-gnu": "11.21.3", "@oxc-resolver/binding-linux-arm64-musl": "11.21.3", "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-musl": "11.21.3", "@oxc-resolver/binding-linux-s390x-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-musl": "11.21.3", "@oxc-resolver/binding-openharmony-arm64": "11.21.3", "@oxc-resolver/binding-wasm32-wasi": "11.21.3", "@oxc-resolver/binding-win32-arm64-msvc": "11.21.3", "@oxc-resolver/binding-win32-x64-msvc": "11.21.3" } }, "sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA=="], + "oxc-resolver": ["oxc-resolver@11.21.3", "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.21.3.tgz", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.21.3", "@oxc-resolver/binding-android-arm64": "11.21.3", "@oxc-resolver/binding-darwin-arm64": "11.21.3", "@oxc-resolver/binding-darwin-x64": "11.21.3", "@oxc-resolver/binding-freebsd-x64": "11.21.3", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.3", "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.3", "@oxc-resolver/binding-linux-arm64-gnu": "11.21.3", "@oxc-resolver/binding-linux-arm64-musl": "11.21.3", "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-musl": "11.21.3", "@oxc-resolver/binding-linux-s390x-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-musl": "11.21.3", "@oxc-resolver/binding-openharmony-arm64": "11.21.3", "@oxc-resolver/binding-wasm32-wasi": "11.21.3", "@oxc-resolver/binding-win32-arm64-msvc": "11.21.3", "@oxc-resolver/binding-win32-x64-msvc": "11.21.3" } }, "sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA=="], - "oxfmt": ["oxfmt@0.55.0", "https://registry.npmmirror.com/oxfmt/-/oxfmt-0.55.0.tgz", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.55.0", "@oxfmt/binding-android-arm64": "0.55.0", "@oxfmt/binding-darwin-arm64": "0.55.0", "@oxfmt/binding-darwin-x64": "0.55.0", "@oxfmt/binding-freebsd-x64": "0.55.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.55.0", "@oxfmt/binding-linux-arm-musleabihf": "0.55.0", "@oxfmt/binding-linux-arm64-gnu": "0.55.0", "@oxfmt/binding-linux-arm64-musl": "0.55.0", "@oxfmt/binding-linux-ppc64-gnu": "0.55.0", "@oxfmt/binding-linux-riscv64-gnu": "0.55.0", "@oxfmt/binding-linux-riscv64-musl": "0.55.0", "@oxfmt/binding-linux-s390x-gnu": "0.55.0", "@oxfmt/binding-linux-x64-gnu": "0.55.0", "@oxfmt/binding-linux-x64-musl": "0.55.0", "@oxfmt/binding-openharmony-arm64": "0.55.0", "@oxfmt/binding-win32-arm64-msvc": "0.55.0", "@oxfmt/binding-win32-ia32-msvc": "0.55.0", "@oxfmt/binding-win32-x64-msvc": "0.55.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-jSj2wCTakwgPMxkfiVZX0jf+nX+Nz6xlyAZjqNE0qXTFdCBPYlP6JAN+ODjmealw7DXBjOzYbdsqwBMAZnPZ6A=="], + "oxfmt": ["oxfmt@0.55.0", "https://registry.npmjs.org/oxfmt/-/oxfmt-0.55.0.tgz", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.55.0", "@oxfmt/binding-android-arm64": "0.55.0", "@oxfmt/binding-darwin-arm64": "0.55.0", "@oxfmt/binding-darwin-x64": "0.55.0", "@oxfmt/binding-freebsd-x64": "0.55.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.55.0", "@oxfmt/binding-linux-arm-musleabihf": "0.55.0", "@oxfmt/binding-linux-arm64-gnu": "0.55.0", "@oxfmt/binding-linux-arm64-musl": "0.55.0", "@oxfmt/binding-linux-ppc64-gnu": "0.55.0", "@oxfmt/binding-linux-riscv64-gnu": "0.55.0", "@oxfmt/binding-linux-riscv64-musl": "0.55.0", "@oxfmt/binding-linux-s390x-gnu": "0.55.0", "@oxfmt/binding-linux-x64-gnu": "0.55.0", "@oxfmt/binding-linux-x64-musl": "0.55.0", "@oxfmt/binding-openharmony-arm64": "0.55.0", "@oxfmt/binding-win32-arm64-msvc": "0.55.0", "@oxfmt/binding-win32-ia32-msvc": "0.55.0", "@oxfmt/binding-win32-x64-msvc": "0.55.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-jSj2wCTakwgPMxkfiVZX0jf+nX+Nz6xlyAZjqNE0qXTFdCBPYlP6JAN+ODjmealw7DXBjOzYbdsqwBMAZnPZ6A=="], - "oxlint": ["oxlint@1.70.0", "https://registry.npmmirror.com/oxlint/-/oxlint-1.70.0.tgz", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.70.0", "@oxlint/binding-android-arm64": "1.70.0", "@oxlint/binding-darwin-arm64": "1.70.0", "@oxlint/binding-darwin-x64": "1.70.0", "@oxlint/binding-freebsd-x64": "1.70.0", "@oxlint/binding-linux-arm-gnueabihf": "1.70.0", "@oxlint/binding-linux-arm-musleabihf": "1.70.0", "@oxlint/binding-linux-arm64-gnu": "1.70.0", "@oxlint/binding-linux-arm64-musl": "1.70.0", "@oxlint/binding-linux-ppc64-gnu": "1.70.0", "@oxlint/binding-linux-riscv64-gnu": "1.70.0", "@oxlint/binding-linux-riscv64-musl": "1.70.0", "@oxlint/binding-linux-s390x-gnu": "1.70.0", "@oxlint/binding-linux-x64-gnu": "1.70.0", "@oxlint/binding-linux-x64-musl": "1.70.0", "@oxlint/binding-openharmony-arm64": "1.70.0", "@oxlint/binding-win32-arm64-msvc": "1.70.0", "@oxlint/binding-win32-ia32-msvc": "1.70.0", "@oxlint/binding-win32-x64-msvc": "1.70.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-D6JgHtzkhRwvEC+A0Nw5AEc5bk8x5i1pHzvZIEf/a0C4hOzmAACNGtkDGPyFaxxX3ZVGxCPeig3P3rMM8XU3/g=="], + "oxlint": ["oxlint@1.70.0", "https://registry.npmjs.org/oxlint/-/oxlint-1.70.0.tgz", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.70.0", "@oxlint/binding-android-arm64": "1.70.0", "@oxlint/binding-darwin-arm64": "1.70.0", "@oxlint/binding-darwin-x64": "1.70.0", "@oxlint/binding-freebsd-x64": "1.70.0", "@oxlint/binding-linux-arm-gnueabihf": "1.70.0", "@oxlint/binding-linux-arm-musleabihf": "1.70.0", "@oxlint/binding-linux-arm64-gnu": "1.70.0", "@oxlint/binding-linux-arm64-musl": "1.70.0", "@oxlint/binding-linux-ppc64-gnu": "1.70.0", "@oxlint/binding-linux-riscv64-gnu": "1.70.0", "@oxlint/binding-linux-riscv64-musl": "1.70.0", "@oxlint/binding-linux-s390x-gnu": "1.70.0", "@oxlint/binding-linux-x64-gnu": "1.70.0", "@oxlint/binding-linux-x64-musl": "1.70.0", "@oxlint/binding-openharmony-arm64": "1.70.0", "@oxlint/binding-win32-arm64-msvc": "1.70.0", "@oxlint/binding-win32-ia32-msvc": "1.70.0", "@oxlint/binding-win32-x64-msvc": "1.70.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-D6JgHtzkhRwvEC+A0Nw5AEc5bk8x5i1pHzvZIEf/a0C4hOzmAACNGtkDGPyFaxxX3ZVGxCPeig3P3rMM8XU3/g=="], - "oxlint-plugin-react-doctor": ["oxlint-plugin-react-doctor@0.5.8", "https://registry.npmmirror.com/oxlint-plugin-react-doctor/-/oxlint-plugin-react-doctor-0.5.8.tgz", { "dependencies": { "@typescript-eslint/types": "^8.59.3", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "oxc-parser": "^0.135.0" } }, "sha512-L0jveKAMbqF1qAqA2Ksu8aH0/Q8FDQxLwXmHYgALa2XlsxEUuamJ+1Da2MhPWJ2ahn+ekFbnWK20qixxD+fw6A=="], + "oxlint-plugin-react-doctor": ["oxlint-plugin-react-doctor@0.5.8", "https://registry.npmjs.org/oxlint-plugin-react-doctor/-/oxlint-plugin-react-doctor-0.5.8.tgz", { "dependencies": { "@typescript-eslint/types": "^8.59.3", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "oxc-parser": "^0.135.0" } }, "sha512-L0jveKAMbqF1qAqA2Ksu8aH0/Q8FDQxLwXmHYgALa2XlsxEUuamJ+1Da2MhPWJ2ahn+ekFbnWK20qixxD+fw6A=="], - "oxlint-tsgolint": ["oxlint-tsgolint@0.23.0", "https://registry.npmmirror.com/oxlint-tsgolint/-/oxlint-tsgolint-0.23.0.tgz", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.23.0", "@oxlint-tsgolint/darwin-x64": "0.23.0", "@oxlint-tsgolint/linux-arm64": "0.23.0", "@oxlint-tsgolint/linux-x64": "0.23.0", "@oxlint-tsgolint/win32-arm64": "0.23.0", "@oxlint-tsgolint/win32-x64": "0.23.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA=="], + "oxlint-tsgolint": ["oxlint-tsgolint@0.23.0", "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.23.0.tgz", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.23.0", "@oxlint-tsgolint/darwin-x64": "0.23.0", "@oxlint-tsgolint/linux-arm64": "0.23.0", "@oxlint-tsgolint/linux-x64": "0.23.0", "@oxlint-tsgolint/win32-arm64": "0.23.0", "@oxlint-tsgolint/win32-x64": "0.23.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA=="], - "p-limit": ["p-limit@3.1.0", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "p-limit": ["p-limit@3.1.0", "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - "p-locate": ["p-locate@5.0.0", "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-locate": ["p-locate@5.0.0", "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - "package-manager-detector": ["package-manager-detector@1.6.0", "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + "package-manager-detector": ["package-manager-detector@1.6.0", "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], - "parse-entities": ["parse-entities@4.0.2", "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + "parse-entities": ["parse-entities@4.0.2", "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], - "path-exists": ["path-exists@4.0.0", "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-exists": ["path-exists@4.0.0", "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - "path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-key": ["path-key@3.1.1", "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pathe": ["pathe@2.0.3", "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "pdfjs-dist": ["pdfjs-dist@6.0.227", "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-6.0.227.tgz", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.0" } }, "sha512-/P6M4SXw+70waMVLUM7rdRtvo+dEzqE1t6W/zQNvBETo2MaRa5rrvCcAYdfWGiUzadTgM0lJmRApUrW0d9zgKg=="], + "pdfjs-dist": ["pdfjs-dist@6.0.227", "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.0.227.tgz", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.0" } }, "sha512-/P6M4SXw+70waMVLUM7rdRtvo+dEzqE1t6W/zQNvBETo2MaRa5rrvCcAYdfWGiUzadTgM0lJmRApUrW0d9zgKg=="], - "picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "picocolors": ["picocolors@1.1.1", "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.3", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "picomatch": ["picomatch@4.0.3", "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "pngjs": ["pngjs@7.0.0", "https://registry.npmmirror.com/pngjs/-/pngjs-7.0.0.tgz", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "pngjs": ["pngjs@7.0.0", "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], - "portfinder": ["portfinder@1.0.37", "https://registry.npmmirror.com/portfinder/-/portfinder-1.0.37.tgz", { "dependencies": { "async": "^3.2.6", "debug": "^4.3.6" } }, "sha512-yuGIEjDAYnnOex9ddMnKZEMFE0CcGo6zbfzDklkmT1m5z734ss6JMzN9rNB3+RR7iS+F10D4/BVIaXOyh8PQKw=="], + "portfinder": ["portfinder@1.0.37", "https://registry.npmjs.org/portfinder/-/portfinder-1.0.37.tgz", { "dependencies": { "async": "^3.2.6", "debug": "^4.3.6" } }, "sha512-yuGIEjDAYnnOex9ddMnKZEMFE0CcGo6zbfzDklkmT1m5z734ss6JMzN9rNB3+RR7iS+F10D4/BVIaXOyh8PQKw=="], - "postcss": ["postcss@8.5.6", "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "postcss": ["postcss@8.5.6", "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], - "preact": ["preact@10.29.2", "https://registry.npmmirror.com/preact/-/preact-10.29.2.tgz", {}, "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ=="], + "preact": ["preact@10.29.2", "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", {}, "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ=="], - "prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "pretty-format": ["pretty-format@27.5.1", "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "pretty-format": ["pretty-format@27.5.1", "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], - "prompts": ["prompts@2.4.2", "https://registry.npmmirror.com/prompts/-/prompts-2.4.2.tgz", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "prompts": ["prompts@2.4.2", "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], - "prop-types": ["prop-types@15.8.1", "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "prop-types": ["prop-types@15.8.1", "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - "punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "punycode": ["punycode@2.3.1", "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "pure-rand": ["pure-rand@6.1.0", "https://registry.npmmirror.com/pure-rand/-/pure-rand-6.1.0.tgz", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "pure-rand": ["pure-rand@6.1.0", "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], - "queue-microtask": ["queue-microtask@1.2.3", "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "queue-microtask": ["queue-microtask@1.2.3", "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "react": ["react@19.2.7", "https://registry.npmmirror.com/react/-/react-19.2.7.tgz", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + "react": ["react@19.2.7", "https://registry.npmjs.org/react/-/react-19.2.7.tgz", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], - "react-day-picker": ["react-day-picker@10.0.1", "https://registry.npmmirror.com/react-day-picker/-/react-day-picker-10.0.1.tgz", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "react": ">=16.8.0" }, "optionalPeers": ["@types/react"] }, "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w=="], + "react-day-picker": ["react-day-picker@10.0.1", "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "react": ">=16.8.0" }, "optionalPeers": ["@types/react"] }, "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w=="], - "react-doctor": ["react-doctor@0.5.8", "https://registry.npmmirror.com/react-doctor/-/react-doctor-0.5.8.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@sentry/node": "^10.54.0", "agent-install": "0.0.5", "conf": "^15.1.0", "confbox": "^0.2.4", "deslop-js": "0.5.8", "eslint-plugin-react-hooks": "^7.1.1", "jiti": "^2.7.0", "magicast": "^0.5.3", "oxlint": ">=1.66.0 <1.67.0", "oxlint-plugin-react-doctor": "0.5.8", "prompts": "^2.4.2", "typescript": ">=5.0.4 <7", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.12", "vscode-uri": "^3.1.0" }, "bin": { "react-doctor": "bin/react-doctor.js" } }, "sha512-gDXDQ+48KeFq2jkVgsUhQ67oQ+kUMdnIaA+YeS9VXXZFXSg9wxY5dDxurEpILSh8RwO06W8p6uCnTR+wn5B/GA=="], + "react-doctor": ["react-doctor@0.5.8", "https://registry.npmjs.org/react-doctor/-/react-doctor-0.5.8.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@sentry/node": "^10.54.0", "agent-install": "0.0.5", "conf": "^15.1.0", "confbox": "^0.2.4", "deslop-js": "0.5.8", "eslint-plugin-react-hooks": "^7.1.1", "jiti": "^2.7.0", "magicast": "^0.5.3", "oxlint": ">=1.66.0 <1.67.0", "oxlint-plugin-react-doctor": "0.5.8", "prompts": "^2.4.2", "typescript": ">=5.0.4 <7", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.12", "vscode-uri": "^3.1.0" }, "bin": { "react-doctor": "bin/react-doctor.js" } }, "sha512-gDXDQ+48KeFq2jkVgsUhQ67oQ+kUMdnIaA+YeS9VXXZFXSg9wxY5dDxurEpILSh8RwO06W8p6uCnTR+wn5B/GA=="], - "react-dom": ["react-dom@19.2.7", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.7.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + "react-dom": ["react-dom@19.2.7", "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], - "react-grab": ["react-grab@0.1.47", "https://registry.npmmirror.com/react-grab/-/react-grab-0.1.47.tgz", { "dependencies": { "@react-grab/cli": "0.1.47", "bippy": "^0.5.41" }, "peerDependencies": { "react": ">=17.0.0" }, "optionalPeers": ["react"], "bin": { "react-grab": "bin/cli.js" } }, "sha512-1GNy24KMJ4CY1IxorYO9mydItGi0L1HkQB19uYU3t0BMsJB0K+D/QYiaBz+rugRynyY8LzmXIuOcon1TykLlCg=="], + "react-grab": ["react-grab@0.1.47", "https://registry.npmjs.org/react-grab/-/react-grab-0.1.47.tgz", { "dependencies": { "@react-grab/cli": "0.1.47", "bippy": "^0.5.41" }, "peerDependencies": { "react": ">=17.0.0" }, "optionalPeers": ["react"], "bin": { "react-grab": "bin/cli.js" } }, "sha512-1GNy24KMJ4CY1IxorYO9mydItGi0L1HkQB19uYU3t0BMsJB0K+D/QYiaBz+rugRynyY8LzmXIuOcon1TykLlCg=="], - "react-hook-form": ["react-hook-form@7.83.0", "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.83.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A=="], + "react-hook-form": ["react-hook-form@7.83.0", "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.83.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A=="], - "react-is": ["react-is@17.0.2", "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-is": ["react-is@17.0.2", "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], - "react-pdf": ["react-pdf@10.4.1", "https://registry.npmmirror.com/react-pdf/-/react-pdf-10.4.1.tgz", { "dependencies": { "clsx": "^2.0.0", "dequal": "^2.0.3", "make-cancellable-promise": "^2.0.0", "make-event-props": "^2.0.0", "merge-refs": "^2.0.0", "pdfjs-dist": "5.4.296", "tiny-invariant": "^1.0.0", "warning": "^4.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA=="], + "react-pdf": ["react-pdf@10.4.1", "https://registry.npmjs.org/react-pdf/-/react-pdf-10.4.1.tgz", { "dependencies": { "clsx": "^2.0.0", "dequal": "^2.0.3", "make-cancellable-promise": "^2.0.0", "make-event-props": "^2.0.0", "merge-refs": "^2.0.0", "pdfjs-dist": "5.4.296", "tiny-invariant": "^1.0.0", "warning": "^4.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA=="], - "react-redux": ["react-redux@9.3.0", "https://registry.npmmirror.com/react-redux/-/react-redux-9.3.0.tgz", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g=="], + "react-redux": ["react-redux@9.3.0", "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g=="], - "react-remove-scroll": ["react-remove-scroll@2.7.2", "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + "react-remove-scroll": ["react-remove-scroll@2.7.2", "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], - "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "https://registry.npmmirror.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], - "react-resizable-panels": ["react-resizable-panels@4.12.2", "https://registry.npmmirror.com/react-resizable-panels/-/react-resizable-panels-4.12.2.tgz", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q=="], + "react-resizable-panels": ["react-resizable-panels@4.12.2", "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.12.2.tgz", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q=="], - "react-scan": ["react-scan@0.5.7", "https://registry.npmmirror.com/react-scan/-/react-scan-0.5.7.tgz", { "dependencies": { "@babel/core": "^7.29.0", "@babel/types": "^7.29.0", "@preact/signals": "^2.9.0", "@rollup/pluginutils": "^5.3.0", "bippy": "^0.5.39", "commander": "^14.0.0", "picocolors": "^1.1.1", "preact": "^10.29.1", "prompts": "^2.4.2", "react-doctor": "latest", "react-grab": "latest" }, "optionalDependencies": { "unplugin": "^3.0.0" }, "peerDependencies": { "esbuild": ">=0.18.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["esbuild"], "bin": { "react-scan": "bin/cli.js" } }, "sha512-KRlq734yN6q/f2CZmZi9CWHuiqSzoLhPFLtcJOL6XM4lR54myyFcY81pG9QOwj+eBC1hIHm5n+Ntbtqiilu8Rg=="], + "react-scan": ["react-scan@0.5.7", "https://registry.npmjs.org/react-scan/-/react-scan-0.5.7.tgz", { "dependencies": { "@babel/core": "^7.29.0", "@babel/types": "^7.29.0", "@preact/signals": "^2.9.0", "@rollup/pluginutils": "^5.3.0", "bippy": "^0.5.39", "commander": "^14.0.0", "picocolors": "^1.1.1", "preact": "^10.29.1", "prompts": "^2.4.2", "react-doctor": "latest", "react-grab": "latest" }, "optionalDependencies": { "unplugin": "^3.0.0" }, "peerDependencies": { "esbuild": ">=0.18.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["esbuild"], "bin": { "react-scan": "bin/cli.js" } }, "sha512-KRlq734yN6q/f2CZmZi9CWHuiqSzoLhPFLtcJOL6XM4lR54myyFcY81pG9QOwj+eBC1hIHm5n+Ntbtqiilu8Rg=="], - "react-style-singleton": ["react-style-singleton@2.2.3", "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "react-style-singleton": ["react-style-singleton@2.2.3", "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - "recharts": ["recharts@3.8.0", "https://registry.npmmirror.com/recharts/-/recharts-3.8.0.tgz", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ=="], + "recharts": ["recharts@3.8.0", "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ=="], - "redux": ["redux@5.0.1", "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + "redux": ["redux@5.0.1", "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], - "redux-thunk": ["redux-thunk@3.1.0", "https://registry.npmmirror.com/redux-thunk/-/redux-thunk-3.1.0.tgz", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "redux-thunk": ["redux-thunk@3.1.0", "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], - "require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "require-from-string": ["require-from-string@2.0.2", "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "require-in-the-middle": ["require-in-the-middle@8.0.1", "https://registry.npmmirror.com/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "require-in-the-middle": ["require-in-the-middle@8.0.1", "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], - "reselect": ["reselect@5.1.1", "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], + "reselect": ["reselect@5.1.1", "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], - "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - "restore-cursor": ["restore-cursor@5.1.0", "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-5.1.0.tgz", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "restore-cursor": ["restore-cursor@5.1.0", "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], - "reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "reusify": ["reusify@1.1.0", "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - "rolldown": ["rolldown@1.2.4", "https://registry.npmmirror.com/rolldown/-/rolldown-1.2.4.tgz", { "dependencies": { "@oxc-project/types": "=0.144.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.4", "@rolldown/binding-darwin-arm64": "1.2.4", "@rolldown/binding-darwin-x64": "1.2.4", "@rolldown/binding-freebsd-x64": "1.2.4", "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", "@rolldown/binding-linux-arm64-gnu": "1.2.4", "@rolldown/binding-linux-arm64-musl": "1.2.4", "@rolldown/binding-linux-ppc64-gnu": "1.2.4", "@rolldown/binding-linux-s390x-gnu": "1.2.4", "@rolldown/binding-linux-x64-gnu": "1.2.4", "@rolldown/binding-linux-x64-musl": "1.2.4", "@rolldown/binding-openharmony-arm64": "1.2.4", "@rolldown/binding-win32-arm64-msvc": "1.2.4", "@rolldown/binding-win32-x64-msvc": "1.2.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w=="], + "rolldown": ["rolldown@1.2.4", "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", { "dependencies": { "@oxc-project/types": "=0.144.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.4", "@rolldown/binding-darwin-arm64": "1.2.4", "@rolldown/binding-darwin-x64": "1.2.4", "@rolldown/binding-freebsd-x64": "1.2.4", "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", "@rolldown/binding-linux-arm64-gnu": "1.2.4", "@rolldown/binding-linux-arm64-musl": "1.2.4", "@rolldown/binding-linux-ppc64-gnu": "1.2.4", "@rolldown/binding-linux-s390x-gnu": "1.2.4", "@rolldown/binding-linux-x64-gnu": "1.2.4", "@rolldown/binding-linux-x64-musl": "1.2.4", "@rolldown/binding-openharmony-arm64": "1.2.4", "@rolldown/binding-win32-arm64-msvc": "1.2.4", "@rolldown/binding-win32-x64-msvc": "1.2.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w=="], - "run-parallel": ["run-parallel@1.2.0", "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "run-parallel": ["run-parallel@1.2.0", "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], - "rxjs": ["rxjs@7.8.2", "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "rxjs": ["rxjs@7.8.2", "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], - "sade": ["sade@1.8.1", "https://registry.npmmirror.com/sade/-/sade-1.8.1.tgz", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + "sade": ["sade@1.8.1", "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], - "scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "scheduler": ["scheduler@0.27.0", "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semifies": ["semifies@1.0.0", "https://registry.npmmirror.com/semifies/-/semifies-1.0.0.tgz", {}, "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw=="], + "semifies": ["semifies@1.0.0", "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz", {}, "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw=="], - "semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semver": ["semver@6.3.1", "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + "shebang-command": ["shebang-command@2.0.0", "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - "shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shell-quote": ["shell-quote@1.8.4", "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.8.4.tgz", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="], + "shell-quote": ["shell-quote@1.8.4", "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="], - "siginfo": ["siginfo@2.0.0", "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "siginfo": ["siginfo@2.0.0", "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "signal-exit": ["signal-exit@4.1.0", "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "signal-exit": ["signal-exit@4.1.0", "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "sirv": ["sirv@3.0.2", "https://registry.npmmirror.com/sirv/-/sirv-3.0.2.tgz", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "sirv": ["sirv@3.0.2", "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], - "sisteransi": ["sisteransi@1.0.5", "https://registry.npmmirror.com/sisteransi/-/sisteransi-1.0.5.tgz", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "sisteransi": ["sisteransi@1.0.5", "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - "sonner": ["sonner@2.0.7", "https://registry.npmmirror.com/sonner/-/sonner-2.0.7.tgz", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "sonner": ["sonner@2.0.7", "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], - "source-map": ["source-map@0.6.1", "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map": ["source-map@0.6.1", "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-js": ["source-map-js@1.2.1", "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "stackback": ["stackback@0.0.2", "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "stackback": ["stackback@0.0.2", "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - "std-env": ["std-env@4.0.0", "https://registry.npmmirror.com/std-env/-/std-env-4.0.0.tgz", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + "std-env": ["std-env@4.0.0", "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], - "stdin-discarder": ["stdin-discarder@0.3.2", "https://registry.npmmirror.com/stdin-discarder/-/stdin-discarder-0.3.2.tgz", {}, "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="], + "stdin-discarder": ["stdin-discarder@0.3.2", "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", {}, "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="], - "string-width": ["string-width@7.2.0", "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string-width": ["string-width@7.2.0", "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "stringify-entities": ["stringify-entities@4.0.4", "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + "stringify-entities": ["stringify-entities@4.0.4", "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], - "strip-ansi": ["strip-ansi@7.2.0", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "strip-ansi": ["strip-ansi@7.2.0", "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "stubborn-fs": ["stubborn-fs@2.0.0", "https://registry.npmmirror.com/stubborn-fs/-/stubborn-fs-2.0.0.tgz", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="], + "stubborn-fs": ["stubborn-fs@2.0.0", "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="], - "stubborn-utils": ["stubborn-utils@1.0.2", "https://registry.npmmirror.com/stubborn-utils/-/stubborn-utils-1.0.2.tgz", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], + "stubborn-utils": ["stubborn-utils@1.0.2", "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], - "style-mod": ["style-mod@4.1.3", "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], + "style-mod": ["style-mod@4.1.3", "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], - "supports-color": ["supports-color@10.2.2", "https://registry.npmmirror.com/supports-color/-/supports-color-10.2.2.tgz", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "supports-color": ["supports-color@10.2.2", "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], - "tabbable": ["tabbable@6.5.0", "https://registry.npmmirror.com/tabbable/-/tabbable-6.5.0.tgz", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], + "tabbable": ["tabbable@6.5.0", "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], - "tagged-tag": ["tagged-tag@1.0.0", "https://registry.npmmirror.com/tagged-tag/-/tagged-tag-1.0.0.tgz", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "tagged-tag": ["tagged-tag@1.0.0", "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], - "tailwind-merge": ["tailwind-merge@3.6.0", "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.6.0.tgz", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], + "tailwind-merge": ["tailwind-merge@3.6.0", "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], - "tailwindcss": ["tailwindcss@4.3.1", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.1.tgz", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + "tailwindcss": ["tailwindcss@4.3.1", "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], - "tapable": ["tapable@2.3.3", "https://registry.npmmirror.com/tapable/-/tapable-2.3.3.tgz", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "tapable": ["tapable@2.3.3", "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "thinking-orbs": ["thinking-orbs@0.2.0", "https://registry.npmmirror.com/thinking-orbs/-/thinking-orbs-0.2.0.tgz", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-CQux/YsLlB9nY60BDpZ/uW7knAvXg+U2T/gzrzuLRw8xlzfJPLXZxAg2tbbIBGp/nE2Kol1GfbMOJnoH+tcb/A=="], + "thinking-orbs": ["thinking-orbs@0.2.0", "https://registry.npmjs.org/thinking-orbs/-/thinking-orbs-0.2.0.tgz", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-CQux/YsLlB9nY60BDpZ/uW7knAvXg+U2T/gzrzuLRw8xlzfJPLXZxAg2tbbIBGp/nE2Kol1GfbMOJnoH+tcb/A=="], - "tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], - "tinybench": ["tinybench@2.9.0", "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinybench": ["tinybench@2.9.0", "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyexec": ["tinyexec@1.2.4", "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.2.4.tgz", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + "tinyexec": ["tinyexec@1.2.4", "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], - "tinyglobby": ["tinyglobby@0.2.15", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyglobby": ["tinyglobby@0.2.15", "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - "tinypool": ["tinypool@2.1.0", "https://registry.npmmirror.com/tinypool/-/tinypool-2.1.0.tgz", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tinypool": ["tinypool@2.1.0", "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], - "tinyrainbow": ["tinyrainbow@3.1.0", "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-3.1.0.tgz", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], - "to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - "totalist": ["totalist@3.0.1", "https://registry.npmmirror.com/totalist/-/totalist-3.0.1.tgz", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "totalist": ["totalist@3.0.1", "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], - "tree-kill": ["tree-kill@1.2.2", "https://registry.npmmirror.com/tree-kill/-/tree-kill-1.2.2.tgz", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + "tree-kill": ["tree-kill@1.2.2", "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], - "tree-sitter": ["tree-sitter@0.21.1", "https://registry.npmmirror.com/tree-sitter/-/tree-sitter-0.21.1.tgz", { "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.0" } }, "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ=="], + "tree-sitter": ["tree-sitter@0.21.1", "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", { "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.0" } }, "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ=="], "tree-sitter-astro": ["tree-sitter-astro@github:virchau13/tree-sitter-astro#213f6e6", { "dependencies": { "nan": "^2.17.0", "tree-sitter-html": "github:tree-sitter/tree-sitter-html" } }, "virchau13-tree-sitter-astro-213f6e6", "sha512-ZSo6FCi7nQ8cQDQlshmHsh+I0fE2kygbrwQy+eqod6uTywZhh0yad0TL7O9UuzJT8Bxo2nQJcpEMj78PJBA3iQ=="], - "tree-sitter-bash": ["tree-sitter-bash@0.25.1", "https://registry.npmmirror.com/tree-sitter-bash/-/tree-sitter-bash-0.25.1.tgz", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-7hMytuYIMoXOq24yRulgIxthE9YmggZIOHCyPTTuJcu6EU54tYD+4G39cUb28kxC6jMf/AbPfWGLQtgPTdh3xw=="], + "tree-sitter-bash": ["tree-sitter-bash@0.25.1", "https://registry.npmjs.org/tree-sitter-bash/-/tree-sitter-bash-0.25.1.tgz", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-7hMytuYIMoXOq24yRulgIxthE9YmggZIOHCyPTTuJcu6EU54tYD+4G39cUb28kxC6jMf/AbPfWGLQtgPTdh3xw=="], - "tree-sitter-c": ["tree-sitter-c@0.24.1", "https://registry.npmmirror.com/tree-sitter-c/-/tree-sitter-c-0.24.1.tgz", { "dependencies": { "node-addon-api": "^8.3.1", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.4" }, "optionalPeers": ["tree-sitter"] }, "sha512-lkYwWN3SRecpvaeqmFKkuPNR3ZbtnvHU+4XAEEkJdrp3JfSp2pBrhXOtvfsENUneye76g889Y0ddF2DM0gEDpA=="], + "tree-sitter-c": ["tree-sitter-c@0.24.1", "https://registry.npmjs.org/tree-sitter-c/-/tree-sitter-c-0.24.1.tgz", { "dependencies": { "node-addon-api": "^8.3.1", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.4" }, "optionalPeers": ["tree-sitter"] }, "sha512-lkYwWN3SRecpvaeqmFKkuPNR3ZbtnvHU+4XAEEkJdrp3JfSp2pBrhXOtvfsENUneye76g889Y0ddF2DM0gEDpA=="], - "tree-sitter-c-sharp": ["tree-sitter-c-sharp@0.23.5", "https://registry.npmmirror.com/tree-sitter-c-sharp/-/tree-sitter-c-sharp-0.23.5.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-xJGOeXPMmld0nES5+080N/06yY6LQi+KWGWV4LfZaZe6srJPtUtfhIbRSN7EZN6IaauzW28v6W4QHFwmeUW6HQ=="], + "tree-sitter-c-sharp": ["tree-sitter-c-sharp@0.23.5", "https://registry.npmjs.org/tree-sitter-c-sharp/-/tree-sitter-c-sharp-0.23.5.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-xJGOeXPMmld0nES5+080N/06yY6LQi+KWGWV4LfZaZe6srJPtUtfhIbRSN7EZN6IaauzW28v6W4QHFwmeUW6HQ=="], - "tree-sitter-cli": ["tree-sitter-cli@0.26.9", "https://registry.npmmirror.com/tree-sitter-cli/-/tree-sitter-cli-0.26.9.tgz", { "bin": { "tree-sitter": "cli.js" } }, "sha512-7l+U1RmazPVe+yA/JiX80GFOILnL/j24GbawamIzNQC8UlINrcyECbaWGaG1wuq4j/m0DQTx7Uu4r0iW9Ao1BQ=="], + "tree-sitter-cli": ["tree-sitter-cli@0.26.9", "https://registry.npmjs.org/tree-sitter-cli/-/tree-sitter-cli-0.26.9.tgz", { "bin": { "tree-sitter": "cli.js" } }, "sha512-7l+U1RmazPVe+yA/JiX80GFOILnL/j24GbawamIzNQC8UlINrcyECbaWGaG1wuq4j/m0DQTx7Uu4r0iW9Ao1BQ=="], - "tree-sitter-cpp": ["tree-sitter-cpp@0.23.4", "https://registry.npmmirror.com/tree-sitter-cpp/-/tree-sitter-cpp-0.23.4.tgz", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2", "tree-sitter-c": "^0.23.1" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-qR5qUDyhZ5jJ6V8/umiBxokRbe89bCGmcq/dk94wI4kN86qfdV8k0GHIUEKaqWgcu42wKal5E97LKpLeVW8sKw=="], + "tree-sitter-cpp": ["tree-sitter-cpp@0.23.4", "https://registry.npmjs.org/tree-sitter-cpp/-/tree-sitter-cpp-0.23.4.tgz", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2", "tree-sitter-c": "^0.23.1" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-qR5qUDyhZ5jJ6V8/umiBxokRbe89bCGmcq/dk94wI4kN86qfdV8k0GHIUEKaqWgcu42wKal5E97LKpLeVW8sKw=="], - "tree-sitter-css": ["tree-sitter-css@0.25.0", "https://registry.npmmirror.com/tree-sitter-css/-/tree-sitter-css-0.25.0.tgz", { "dependencies": { "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-FRc9R8ePrwJiUhZsuZ/wcFQ3K8Z+9yCgDrrUjuYswGWlN89UvcB9vslTUGZElQWGwhS8sUw3/r2n4lpb2sxT4Q=="], + "tree-sitter-css": ["tree-sitter-css@0.25.0", "https://registry.npmjs.org/tree-sitter-css/-/tree-sitter-css-0.25.0.tgz", { "dependencies": { "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-FRc9R8ePrwJiUhZsuZ/wcFQ3K8Z+9yCgDrrUjuYswGWlN89UvcB9vslTUGZElQWGwhS8sUw3/r2n4lpb2sxT4Q=="], - "tree-sitter-dart": ["tree-sitter-dart@1.0.0", "https://registry.npmmirror.com/tree-sitter-dart/-/tree-sitter-dart-1.0.0.tgz", { "dependencies": { "nan": "^2.15.0" } }, "sha512-Ve5YMPJjjGW9LEsO+MngAOibQsw5obFp+bUT41pvwdcXWRwJImOWs3eaPi6AubEiBmc09qvhdvxeIXvxlhMnug=="], + "tree-sitter-dart": ["tree-sitter-dart@1.0.0", "https://registry.npmjs.org/tree-sitter-dart/-/tree-sitter-dart-1.0.0.tgz", { "dependencies": { "nan": "^2.15.0" } }, "sha512-Ve5YMPJjjGW9LEsO+MngAOibQsw5obFp+bUT41pvwdcXWRwJImOWs3eaPi6AubEiBmc09qvhdvxeIXvxlhMnug=="], "tree-sitter-diff": ["tree-sitter-diff@github:the-mikedavis/tree-sitter-diff#ada384a", { "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.1" }, "peerDependencies": { "tree-sitter": "^0.21.1" } }, "tree-sitter-grammars-tree-sitter-diff-ada384a", "sha512-zbSHo5tqFJC46qrl7RW0Ctd59xWLd/L6BfOA9oufdWy3qVT+vRIf2C4BIgaiydy3jOMKSEOusWeawLWroGg4QA=="], - "tree-sitter-elisp": ["tree-sitter-elisp@1.6.1", "https://registry.npmmirror.com/tree-sitter-elisp/-/tree-sitter-elisp-1.6.1.tgz", { "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" } }, "sha512-ALJ50YOuqu2Z/qKyQMh3RT5OhQmTRc2K/4MZzPdd1eKyFdKPaYpG0ZmPyovNTXyW14Qn63SgY960el55wLwy8Q=="], + "tree-sitter-elisp": ["tree-sitter-elisp@1.6.1", "https://registry.npmjs.org/tree-sitter-elisp/-/tree-sitter-elisp-1.6.1.tgz", { "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" } }, "sha512-ALJ50YOuqu2Z/qKyQMh3RT5OhQmTRc2K/4MZzPdd1eKyFdKPaYpG0ZmPyovNTXyW14Qn63SgY960el55wLwy8Q=="], - "tree-sitter-elixir": ["tree-sitter-elixir@0.3.5", "https://registry.npmmirror.com/tree-sitter-elixir/-/tree-sitter-elixir-0.3.5.tgz", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.21.0" } }, "sha512-xozQMvYK0aSolcQZAx2d84Xe/YMWFuRPYFlLVxO01bM2GITh5jyiIp0TqPCQa8754UzRAI7A83hZmfiYub5TZQ=="], + "tree-sitter-elixir": ["tree-sitter-elixir@0.3.5", "https://registry.npmjs.org/tree-sitter-elixir/-/tree-sitter-elixir-0.3.5.tgz", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.21.0" } }, "sha512-xozQMvYK0aSolcQZAx2d84Xe/YMWFuRPYFlLVxO01bM2GITh5jyiIp0TqPCQa8754UzRAI7A83hZmfiYub5TZQ=="], - "tree-sitter-go": ["tree-sitter-go@0.25.0", "https://registry.npmmirror.com/tree-sitter-go/-/tree-sitter-go-0.25.0.tgz", { "dependencies": { "node-addon-api": "^8.3.1", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-APBc/Dq3xz/e35Xpkhb1blu5UgW+2E3RyGWawZSCNcbGwa7jhSQPS8KsUupuzBla8PCo8+lz9W/JDJjmfRa2tw=="], + "tree-sitter-go": ["tree-sitter-go@0.25.0", "https://registry.npmjs.org/tree-sitter-go/-/tree-sitter-go-0.25.0.tgz", { "dependencies": { "node-addon-api": "^8.3.1", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-APBc/Dq3xz/e35Xpkhb1blu5UgW+2E3RyGWawZSCNcbGwa7jhSQPS8KsUupuzBla8PCo8+lz9W/JDJjmfRa2tw=="], - "tree-sitter-html": ["tree-sitter-html@0.23.2", "https://registry.npmmirror.com/tree-sitter-html/-/tree-sitter-html-0.23.2.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-TN+l+7cCeLx9db/1RhRSqMAZO/266Oh2BHb8J8hMSSFLuzYvFTYP/UnD3S0mny5awzw05KzFNgu2vnwzN9wVJg=="], + "tree-sitter-html": ["tree-sitter-html@0.23.2", "https://registry.npmjs.org/tree-sitter-html/-/tree-sitter-html-0.23.2.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-TN+l+7cCeLx9db/1RhRSqMAZO/266Oh2BHb8J8hMSSFLuzYvFTYP/UnD3S0mny5awzw05KzFNgu2vnwzN9wVJg=="], - "tree-sitter-java": ["tree-sitter-java@0.23.5", "https://registry.npmmirror.com/tree-sitter-java/-/tree-sitter-java-0.23.5.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-Yju7oQ0Xx7GcUT01mUglPP+bYfvqjNCGdxqigTnew9nLGoII42PNVP3bHrYeMxswiCRM0yubWmN5qk+zsg0zMA=="], + "tree-sitter-java": ["tree-sitter-java@0.23.5", "https://registry.npmjs.org/tree-sitter-java/-/tree-sitter-java-0.23.5.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-Yju7oQ0Xx7GcUT01mUglPP+bYfvqjNCGdxqigTnew9nLGoII42PNVP3bHrYeMxswiCRM0yubWmN5qk+zsg0zMA=="], - "tree-sitter-javascript": ["tree-sitter-javascript@0.25.0", "https://registry.npmmirror.com/tree-sitter-javascript/-/tree-sitter-javascript-0.25.0.tgz", { "dependencies": { "node-addon-api": "^8.3.1", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-1fCbmzAskZkxcZzN41sFZ2br2iqTYP3tKls1b/HKGNPQUVOpsUxpmGxdN/wMqAk3jYZnYBR1dd/y/0avMeU7dw=="], + "tree-sitter-javascript": ["tree-sitter-javascript@0.25.0", "https://registry.npmjs.org/tree-sitter-javascript/-/tree-sitter-javascript-0.25.0.tgz", { "dependencies": { "node-addon-api": "^8.3.1", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-1fCbmzAskZkxcZzN41sFZ2br2iqTYP3tKls1b/HKGNPQUVOpsUxpmGxdN/wMqAk3jYZnYBR1dd/y/0avMeU7dw=="], - "tree-sitter-json": ["tree-sitter-json@0.24.8", "https://registry.npmmirror.com/tree-sitter-json/-/tree-sitter-json-0.24.8.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-Tc9ZZYwHyWZ3Tt1VEw7Pa2scu1YO7/d2BCBbKTx5hXwig3UfdQjsOPkPyLpDJOn/m1UBEWYAtSdGAwCSyagBqQ=="], + "tree-sitter-json": ["tree-sitter-json@0.24.8", "https://registry.npmjs.org/tree-sitter-json/-/tree-sitter-json-0.24.8.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-Tc9ZZYwHyWZ3Tt1VEw7Pa2scu1YO7/d2BCBbKTx5hXwig3UfdQjsOPkPyLpDJOn/m1UBEWYAtSdGAwCSyagBqQ=="], - "tree-sitter-kotlin": ["tree-sitter-kotlin@0.3.8", "https://registry.npmmirror.com/tree-sitter-kotlin/-/tree-sitter-kotlin-0.3.8.tgz", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.21.0" } }, "sha512-A4obq6bjzmYrA+F0JLLoheFPcofFkctNaZSpnDd+GPn1SfVZLY4/GG4C0cYVBTOShuPBGGAOPLM1JWLZQV4m1g=="], + "tree-sitter-kotlin": ["tree-sitter-kotlin@0.3.8", "https://registry.npmjs.org/tree-sitter-kotlin/-/tree-sitter-kotlin-0.3.8.tgz", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.21.0" } }, "sha512-A4obq6bjzmYrA+F0JLLoheFPcofFkctNaZSpnDd+GPn1SfVZLY4/GG4C0cYVBTOShuPBGGAOPLM1JWLZQV4m1g=="], - "tree-sitter-lua": ["tree-sitter-lua@2.1.3", "https://registry.npmmirror.com/tree-sitter-lua/-/tree-sitter-lua-2.1.3.tgz", { "dependencies": { "nan": "^2.15.0" } }, "sha512-BmRSRI0Y4J47cE2cODyXsPiueDSAnIrFLJqOP/gKIJhGa4HoGpvEccmNuhAEVGtCrgaHGhaIkWeqiMGCgQ0cfw=="], + "tree-sitter-lua": ["tree-sitter-lua@2.1.3", "https://registry.npmjs.org/tree-sitter-lua/-/tree-sitter-lua-2.1.3.tgz", { "dependencies": { "nan": "^2.15.0" } }, "sha512-BmRSRI0Y4J47cE2cODyXsPiueDSAnIrFLJqOP/gKIJhGa4HoGpvEccmNuhAEVGtCrgaHGhaIkWeqiMGCgQ0cfw=="], - "tree-sitter-objc": ["tree-sitter-objc@3.0.2", "https://registry.npmmirror.com/tree-sitter-objc/-/tree-sitter-objc-3.0.2.tgz", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4", "tree-sitter-c": "^0.23.4" }, "peerDependencies": { "tree-sitter": "^0.22.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-Hs0ohmx1u5M+0K7efoW+dv/corhBsfjftfIYLtp7dSGeJ+Zj4c33tDIboBYLs6qijRlz6wtHFxa0YX+FibLulA=="], + "tree-sitter-objc": ["tree-sitter-objc@3.0.2", "https://registry.npmjs.org/tree-sitter-objc/-/tree-sitter-objc-3.0.2.tgz", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4", "tree-sitter-c": "^0.23.4" }, "peerDependencies": { "tree-sitter": "^0.22.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-Hs0ohmx1u5M+0K7efoW+dv/corhBsfjftfIYLtp7dSGeJ+Zj4c33tDIboBYLs6qijRlz6wtHFxa0YX+FibLulA=="], - "tree-sitter-ocaml": ["tree-sitter-ocaml@0.24.2", "https://registry.npmmirror.com/tree-sitter-ocaml/-/tree-sitter-ocaml-0.24.2.tgz", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.4" }, "optionalPeers": ["tree-sitter"] }, "sha512-H0RAeCepIyXyTPCQra6yMd7Bn5ZBYkIaddzdLNwVZpM9mCe2e8av+3O6Ojl7Z8YHrV/kYsfHvI2y+Hh7qzcYQQ=="], + "tree-sitter-ocaml": ["tree-sitter-ocaml@0.24.2", "https://registry.npmjs.org/tree-sitter-ocaml/-/tree-sitter-ocaml-0.24.2.tgz", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.4" }, "optionalPeers": ["tree-sitter"] }, "sha512-H0RAeCepIyXyTPCQra6yMd7Bn5ZBYkIaddzdLNwVZpM9mCe2e8av+3O6Ojl7Z8YHrV/kYsfHvI2y+Hh7qzcYQQ=="], - "tree-sitter-php": ["tree-sitter-php@0.24.2", "https://registry.npmmirror.com/tree-sitter-php/-/tree-sitter-php-0.24.2.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.22.4" }, "optionalPeers": ["tree-sitter"] }, "sha512-zwgAePc/HozNaWOOfwRAA+3p8yhuehRw8Fb7vn5qd2XjiIc93uJPryDTMYTSjBRjVIUg/KY6pM3rRzs8dSwKfw=="], + "tree-sitter-php": ["tree-sitter-php@0.24.2", "https://registry.npmjs.org/tree-sitter-php/-/tree-sitter-php-0.24.2.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.22.4" }, "optionalPeers": ["tree-sitter"] }, "sha512-zwgAePc/HozNaWOOfwRAA+3p8yhuehRw8Fb7vn5qd2XjiIc93uJPryDTMYTSjBRjVIUg/KY6pM3rRzs8dSwKfw=="], - "tree-sitter-python": ["tree-sitter-python@0.25.0", "https://registry.npmmirror.com/tree-sitter-python/-/tree-sitter-python-0.25.0.tgz", { "dependencies": { "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-eCmJx6zQa35GxaCtQD+wXHOhYqBxEL+bp71W/s3fcDMu06MrtzkVXR437dRrCrbrDbyLuUDJpAgycs7ncngLXw=="], + "tree-sitter-python": ["tree-sitter-python@0.25.0", "https://registry.npmjs.org/tree-sitter-python/-/tree-sitter-python-0.25.0.tgz", { "dependencies": { "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-eCmJx6zQa35GxaCtQD+wXHOhYqBxEL+bp71W/s3fcDMu06MrtzkVXR437dRrCrbrDbyLuUDJpAgycs7ncngLXw=="], "tree-sitter-rescript": ["tree-sitter-rescript@github:rescript-lang/tree-sitter-rescript#90643a6", { "dependencies": { "nan": "^2.15.0", "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "rescript-lang-tree-sitter-rescript-90643a6", "sha512-o5bLluG6VJ13AlE4zuzyqUJYm/9i6lOjmxcGcrk3cyzDlYFKhOsHHnbTwE0axud4N7iPD2wqfcJ5rji4BkzJYQ=="], - "tree-sitter-ruby": ["tree-sitter-ruby@0.23.1", "https://registry.npmmirror.com/tree-sitter-ruby/-/tree-sitter-ruby-0.23.1.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-d9/RXgWjR6HanN7wTYhS5bpBQLz1VkH048Vm3CodPGyJVnamXMGb8oEhDypVCBq4QnHui9sTXuJBBP3WtCw5RA=="], + "tree-sitter-ruby": ["tree-sitter-ruby@0.23.1", "https://registry.npmjs.org/tree-sitter-ruby/-/tree-sitter-ruby-0.23.1.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-d9/RXgWjR6HanN7wTYhS5bpBQLz1VkH048Vm3CodPGyJVnamXMGb8oEhDypVCBq4QnHui9sTXuJBBP3WtCw5RA=="], - "tree-sitter-rust": ["tree-sitter-rust@0.24.0", "https://registry.npmmirror.com/tree-sitter-rust/-/tree-sitter-rust-0.24.0.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-NWemUDf629Tfc90Y0Z55zuwPCAHkLxWnMf2RznYu4iBkkrQl2o/CHGB7Cr52TyN5F1DAx8FmUnDtCy9iUkXZEQ=="], + "tree-sitter-rust": ["tree-sitter-rust@0.24.0", "https://registry.npmjs.org/tree-sitter-rust/-/tree-sitter-rust-0.24.0.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-NWemUDf629Tfc90Y0Z55zuwPCAHkLxWnMf2RznYu4iBkkrQl2o/CHGB7Cr52TyN5F1DAx8FmUnDtCy9iUkXZEQ=="], - "tree-sitter-scala": ["tree-sitter-scala@0.24.0", "https://registry.npmmirror.com/tree-sitter-scala/-/tree-sitter-scala-0.24.0.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-vkMuAUrBZ1zZz2XcGDQk18Kz73JkpgaeXzbNVobPke0G35sd9jH32aUxG6OLRKM7et0TbsfqkWf4DeJoGk4K1g=="], + "tree-sitter-scala": ["tree-sitter-scala@0.24.0", "https://registry.npmjs.org/tree-sitter-scala/-/tree-sitter-scala-0.24.0.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-vkMuAUrBZ1zZz2XcGDQk18Kz73JkpgaeXzbNVobPke0G35sd9jH32aUxG6OLRKM7et0TbsfqkWf4DeJoGk4K1g=="], - "tree-sitter-solidity": ["tree-sitter-solidity@1.2.13", "https://registry.npmmirror.com/tree-sitter-solidity/-/tree-sitter-solidity-1.2.13.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2", "yarn": "^1.22.21" }, "peerDependencies": { "tree-sitter": "^0.25.0" } }, "sha512-nO2AbcAuz2Qba8JnPNe/3FVjRRvGY3ApxSJ8UPIzfynJm4PYCMbBoXxxbprvMgjCbGYR/ZrHGIPKzXV7zBa+lQ=="], + "tree-sitter-solidity": ["tree-sitter-solidity@1.2.13", "https://registry.npmjs.org/tree-sitter-solidity/-/tree-sitter-solidity-1.2.13.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2", "yarn": "^1.22.21" }, "peerDependencies": { "tree-sitter": "^0.25.0" } }, "sha512-nO2AbcAuz2Qba8JnPNe/3FVjRRvGY3ApxSJ8UPIzfynJm4PYCMbBoXxxbprvMgjCbGYR/ZrHGIPKzXV7zBa+lQ=="], - "tree-sitter-svelte": ["tree-sitter-svelte@0.11.0", "https://registry.npmmirror.com/tree-sitter-svelte/-/tree-sitter-svelte-0.11.0.tgz", { "dependencies": { "nan": "^2.17.0" } }, "sha512-HqhbQ6Q4wMMGe2akVpcoVbhAoSO3Wf5/n0JYIP/9XGlF6kG46lU0II3MNVZANpBk8O90vM9OEKyD/EGrECvxbA=="], + "tree-sitter-svelte": ["tree-sitter-svelte@0.11.0", "https://registry.npmjs.org/tree-sitter-svelte/-/tree-sitter-svelte-0.11.0.tgz", { "dependencies": { "nan": "^2.17.0" } }, "sha512-HqhbQ6Q4wMMGe2akVpcoVbhAoSO3Wf5/n0JYIP/9XGlF6kG46lU0II3MNVZANpBk8O90vM9OEKyD/EGrECvxbA=="], - "tree-sitter-swift": ["tree-sitter-swift@0.7.1", "https://registry.npmmirror.com/tree-sitter-swift/-/tree-sitter-swift-0.7.1.tgz", { "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.0", "tree-sitter-cli": "^0.23", "which": "2.0.2" }, "peerDependencies": { "tree-sitter": "^0.22.1" } }, "sha512-pneKVTuGamaBsqqqfB9BvNQjktzh/0IVPR54jLB5Fq/JTDQwYHd0Wo6pVyZ5jAYpbztzq+rJ/rpL9ruxTmSoKw=="], + "tree-sitter-swift": ["tree-sitter-swift@0.7.1", "https://registry.npmjs.org/tree-sitter-swift/-/tree-sitter-swift-0.7.1.tgz", { "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.0", "tree-sitter-cli": "^0.23", "which": "2.0.2" }, "peerDependencies": { "tree-sitter": "^0.22.1" } }, "sha512-pneKVTuGamaBsqqqfB9BvNQjktzh/0IVPR54jLB5Fq/JTDQwYHd0Wo6pVyZ5jAYpbztzq+rJ/rpL9ruxTmSoKw=="], - "tree-sitter-systemrdl": ["tree-sitter-systemrdl@0.8.0", "https://registry.npmmirror.com/tree-sitter-systemrdl/-/tree-sitter-systemrdl-0.8.0.tgz", { "dependencies": { "nan": "^2.14.2" } }, "sha512-QUAcwUFP+BFa8NEDSOoxDgTPOZEkN8BDVHpbJo5AIpoHLPRE2zEwNgzrJiZFKPsZb9P6uD1oI41WlU20kR/AJw=="], + "tree-sitter-systemrdl": ["tree-sitter-systemrdl@0.8.0", "https://registry.npmjs.org/tree-sitter-systemrdl/-/tree-sitter-systemrdl-0.8.0.tgz", { "dependencies": { "nan": "^2.14.2" } }, "sha512-QUAcwUFP+BFa8NEDSOoxDgTPOZEkN8BDVHpbJo5AIpoHLPRE2zEwNgzrJiZFKPsZb9P6uD1oI41WlU20kR/AJw=="], - "tree-sitter-toml": ["tree-sitter-toml@0.5.1", "https://registry.npmmirror.com/tree-sitter-toml/-/tree-sitter-toml-0.5.1.tgz", { "dependencies": { "nan": "^2.14.0" } }, "sha512-ymaN/Lno2tqTPEuKOOdu4IoqISaL8MWRQGp1/+2yqVAcw9PSBh5diCkoOwumHYv00grzDmY5hUtuairQ68hVkQ=="], + "tree-sitter-toml": ["tree-sitter-toml@0.5.1", "https://registry.npmjs.org/tree-sitter-toml/-/tree-sitter-toml-0.5.1.tgz", { "dependencies": { "nan": "^2.14.0" } }, "sha512-ymaN/Lno2tqTPEuKOOdu4IoqISaL8MWRQGp1/+2yqVAcw9PSBh5diCkoOwumHYv00grzDmY5hUtuairQ68hVkQ=="], - "tree-sitter-typescript": ["tree-sitter-typescript@0.23.2", "https://registry.npmmirror.com/tree-sitter-typescript/-/tree-sitter-typescript-0.23.2.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2", "tree-sitter-javascript": "^0.23.1" }, "peerDependencies": { "tree-sitter": "^0.21.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-e04JUUKxTT53/x3Uq1zIL45DoYKVfHH4CZqwgZhPg5qYROl5nQjV+85ruFzFGZxu+QeFVbRTPDRnqL9UbU4VeA=="], + "tree-sitter-typescript": ["tree-sitter-typescript@0.23.2", "https://registry.npmjs.org/tree-sitter-typescript/-/tree-sitter-typescript-0.23.2.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2", "tree-sitter-javascript": "^0.23.1" }, "peerDependencies": { "tree-sitter": "^0.21.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-e04JUUKxTT53/x3Uq1zIL45DoYKVfHH4CZqwgZhPg5qYROl5nQjV+85ruFzFGZxu+QeFVbRTPDRnqL9UbU4VeA=="], - "tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tslib": ["tslib@2.8.1", "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tsx": ["tsx@4.20.3", "https://registry.npmmirror.com/tsx/-/tsx-4.20.3.tgz", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ=="], + "tsx": ["tsx@4.20.3", "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ=="], - "tw-animate-css": ["tw-animate-css@1.4.0", "https://registry.npmmirror.com/tw-animate-css/-/tw-animate-css-1.4.0.tgz", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + "tw-animate-css": ["tw-animate-css@1.4.0", "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], - "type-check": ["type-check@0.4.0", "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + "type-check": ["type-check@0.4.0", "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "type-fest": ["type-fest@5.7.0", "https://registry.npmmirror.com/type-fest/-/type-fest-5.7.0.tgz", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], + "type-fest": ["type-fest@5.7.0", "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], - "typescript": ["typescript@6.0.3", "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "typescript": ["typescript@6.0.3", "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "typescript-language-server": ["typescript-language-server@5.3.0", "https://registry.npmmirror.com/typescript-language-server/-/typescript-language-server-5.3.0.tgz", { "bin": { "typescript-language-server": "lib/cli.mjs" } }, "sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ=="], + "typescript-language-server": ["typescript-language-server@5.3.0", "https://registry.npmjs.org/typescript-language-server/-/typescript-language-server-5.3.0.tgz", { "bin": { "typescript-language-server": "lib/cli.mjs" } }, "sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ=="], - "uint8array-extras": ["uint8array-extras@1.5.0", "https://registry.npmmirror.com/uint8array-extras/-/uint8array-extras-1.5.0.tgz", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "uint8array-extras": ["uint8array-extras@1.5.0", "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], - "undici-types": ["undici-types@8.3.0", "https://registry.npmmirror.com/undici-types/-/undici-types-8.3.0.tgz", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "undici-types": ["undici-types@8.3.0", "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], - "unidiff": ["unidiff@1.0.4", "https://registry.npmmirror.com/unidiff/-/unidiff-1.0.4.tgz", { "dependencies": { "diff": "^5.1.0" } }, "sha512-ynU0vsAXw0ir8roa+xPCUHmnJ5goc5BTM2Kuc3IJd8UwgaeRs7VSD5+eeaQL+xp1JtB92hu/Zy/Lgy7RZcr1pQ=="], + "unidiff": ["unidiff@1.0.4", "https://registry.npmjs.org/unidiff/-/unidiff-1.0.4.tgz", { "dependencies": { "diff": "^5.1.0" } }, "sha512-ynU0vsAXw0ir8roa+xPCUHmnJ5goc5BTM2Kuc3IJd8UwgaeRs7VSD5+eeaQL+xp1JtB92hu/Zy/Lgy7RZcr1pQ=="], - "unist-util-is": ["unist-util-is@6.0.1", "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.1.tgz", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + "unist-util-is": ["unist-util-is@6.0.1", "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], - "unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "https://registry.npmmirror.com/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="], + "unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="], - "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "https://registry.npmmirror.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], - "unist-util-visit": ["unist-util-visit@5.1.0", "https://registry.npmmirror.com/unist-util-visit/-/unist-util-visit-5.1.0.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + "unist-util-visit": ["unist-util-visit@5.1.0", "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], - "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "https://registry.npmmirror.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], - "unplugin": ["unplugin@3.0.0", "https://registry.npmmirror.com/unplugin/-/unplugin-3.0.0.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], + "unplugin": ["unplugin@3.0.0", "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], - "update-browserslist-db": ["update-browserslist-db@1.1.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], + "update-browserslist-db": ["update-browserslist-db@1.1.3", "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], - "uri-js": ["uri-js@4.4.1", "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "uri-js": ["uri-js@4.4.1", "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "use-callback-ref": ["use-callback-ref@1.3.3", "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], - "use-debounce": ["use-debounce@10.1.1", "https://registry.npmmirror.com/use-debounce/-/use-debounce-10.1.1.tgz", { "peerDependencies": { "react": "*" } }, "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ=="], + "use-debounce": ["use-debounce@10.1.1", "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.1.tgz", { "peerDependencies": { "react": "*" } }, "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ=="], - "use-sidecar": ["use-sidecar@1.1.3", "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "use-sidecar": ["use-sidecar@1.1.3", "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], - "use-sync-external-store": ["use-sync-external-store@1.6.0", "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], - "usehooks-ts": ["usehooks-ts@3.1.1", "https://registry.npmmirror.com/usehooks-ts/-/usehooks-ts-3.1.1.tgz", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="], + "usehooks-ts": ["usehooks-ts@3.1.1", "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="], - "uvu": ["uvu@0.5.6", "https://registry.npmmirror.com/uvu/-/uvu-0.5.6.tgz", { "dependencies": { "dequal": "^2.0.0", "diff": "^5.0.0", "kleur": "^4.0.3", "sade": "^1.7.3" }, "bin": { "uvu": "bin.js" } }, "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA=="], + "uvu": ["uvu@0.5.6", "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", { "dependencies": { "dequal": "^2.0.0", "diff": "^5.0.0", "kleur": "^4.0.3", "sade": "^1.7.3" }, "bin": { "uvu": "bin.js" } }, "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA=="], - "vfile-message": ["vfile-message@4.0.3", "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "vfile-message": ["vfile-message@4.0.3", "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "victory-vendor": ["victory-vendor@37.3.6", "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + "victory-vendor": ["victory-vendor@37.3.6", "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], - "vite": ["@voidzero-dev/vite-plus-core@0.2.1", "https://registry.npmmirror.com/@voidzero-dev/vite-plus-core/-/vite-plus-core-0.2.1.tgz", { "dependencies": { "@oxc-project/runtime": "=0.136.0", "@oxc-project/types": "=0.136.0", "lightningcss": "^1.30.2", "postcss": "^8.5.6" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.3", "@tsdown/exe": "0.22.3", "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "publint": "^0.3.8", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "typescript": "^5.0.0 || ^6.0.0", "unplugin-unused": "^0.5.0", "unrun": "*", "yaml": "^2.4.2" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "publint", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "typescript", "unplugin-unused", "unrun", "yaml"] }, "sha512-iWdtOlLezgYcDqIzxZx1yOUhY93vUB+ob+mRYBNr7/3Hf80uRyTQbqVD1WtsYaANbzeUi81SQ1ZoUraXHO+u8A=="], + "vite": ["@voidzero-dev/vite-plus-core@0.2.1", "https://registry.npmjs.org/@voidzero-dev/vite-plus-core/-/vite-plus-core-0.2.1.tgz", { "dependencies": { "@oxc-project/runtime": "=0.136.0", "@oxc-project/types": "=0.136.0", "lightningcss": "^1.30.2", "postcss": "^8.5.6" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.3", "@tsdown/exe": "0.22.3", "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "publint": "^0.3.8", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "typescript": "^5.0.0 || ^6.0.0", "unplugin-unused": "^0.5.0", "unrun": "*", "yaml": "^2.4.2" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "publint", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "typescript", "unplugin-unused", "unrun", "yaml"] }, "sha512-iWdtOlLezgYcDqIzxZx1yOUhY93vUB+ob+mRYBNr7/3Hf80uRyTQbqVD1WtsYaANbzeUi81SQ1ZoUraXHO+u8A=="], - "vite-plus": ["vite-plus@0.2.1", "https://registry.npmmirror.com/vite-plus/-/vite-plus-0.2.1.tgz", { "dependencies": { "@oxc-project/types": "=0.136.0", "@oxlint/plugins": "=1.68.0", "@vitest/browser": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "@voidzero-dev/vite-plus-core": "0.2.1", "oxfmt": "=0.55.0", "oxlint": "=1.70.0", "oxlint-tsgolint": "=0.23.0", "vitest": "4.1.9" }, "optionalDependencies": { "@voidzero-dev/vite-plus-darwin-arm64": "0.2.1", "@voidzero-dev/vite-plus-darwin-x64": "0.2.1", "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.2.1", "@voidzero-dev/vite-plus-linux-arm64-musl": "0.2.1", "@voidzero-dev/vite-plus-linux-x64-gnu": "0.2.1", "@voidzero-dev/vite-plus-linux-x64-musl": "0.2.1", "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.2.1", "@voidzero-dev/vite-plus-win32-x64-msvc": "0.2.1" }, "peerDependencies": { "@vitest/browser-playwright": "4.1.9", "@vitest/browser-webdriverio": "4.1.9" }, "optionalPeers": ["@vitest/browser-playwright", "@vitest/browser-webdriverio"], "bin": { "oxfmt": "bin/oxfmt", "oxlint": "bin/oxlint", "vp": "bin/vp" } }, "sha512-q5q/Y38UkWFsNg1JO+RyRdPUqoewaSqIlMyK2p83GKNUvf4D38Ntb3PToRTDZbTRh7mWt+B+d0DQBv4nCDpMcQ=="], + "vite-plus": ["vite-plus@0.2.1", "https://registry.npmjs.org/vite-plus/-/vite-plus-0.2.1.tgz", { "dependencies": { "@oxc-project/types": "=0.136.0", "@oxlint/plugins": "=1.68.0", "@vitest/browser": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "@voidzero-dev/vite-plus-core": "0.2.1", "oxfmt": "=0.55.0", "oxlint": "=1.70.0", "oxlint-tsgolint": "=0.23.0", "vitest": "4.1.9" }, "optionalDependencies": { "@voidzero-dev/vite-plus-darwin-arm64": "0.2.1", "@voidzero-dev/vite-plus-darwin-x64": "0.2.1", "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.2.1", "@voidzero-dev/vite-plus-linux-arm64-musl": "0.2.1", "@voidzero-dev/vite-plus-linux-x64-gnu": "0.2.1", "@voidzero-dev/vite-plus-linux-x64-musl": "0.2.1", "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.2.1", "@voidzero-dev/vite-plus-win32-x64-msvc": "0.2.1" }, "peerDependencies": { "@vitest/browser-playwright": "4.1.9", "@vitest/browser-webdriverio": "4.1.9" }, "optionalPeers": ["@vitest/browser-playwright", "@vitest/browser-webdriverio"], "bin": { "oxfmt": "bin/oxfmt", "oxlint": "bin/oxlint", "vp": "bin/vp" } }, "sha512-q5q/Y38UkWFsNg1JO+RyRdPUqoewaSqIlMyK2p83GKNUvf4D38Ntb3PToRTDZbTRh7mWt+B+d0DQBv4nCDpMcQ=="], - "vitest": ["vitest@4.1.9", "https://registry.npmmirror.com/vitest/-/vitest-4.1.9.tgz", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "vitest": ["vitest@4.1.9", "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], - "vscode-jsonrpc": ["vscode-jsonrpc@9.0.0", "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0.tgz", {}, "sha512-+VvMmQPJhtvJ+8O+zu2JKIRiLxXF8NW7krWgyMGeOHrp4Cn23T5hc0v2LknNeopDOB70wghHAds7mKtcZ0I4Sg=="], + "vscode-jsonrpc": ["vscode-jsonrpc@9.0.0", "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0.tgz", {}, "sha512-+VvMmQPJhtvJ+8O+zu2JKIRiLxXF8NW7krWgyMGeOHrp4Cn23T5hc0v2LknNeopDOB70wghHAds7mKtcZ0I4Sg=="], - "vscode-languageserver": ["vscode-languageserver@9.0.1", "https://registry.npmmirror.com/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], + "vscode-languageserver": ["vscode-languageserver@9.0.1", "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], - "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.18.1", "https://registry.npmmirror.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.1.tgz", { "dependencies": { "vscode-jsonrpc": "9.0.0", "vscode-languageserver-types": "3.18.0" } }, "sha512-RTiiVHdpxpYcJVI5sq6S5TLjQ4WDR/rBrIWru+kPXe6sGQ9PFQ3GamrTKLvPqbR4ylr1SoodhmcqbFII0WXVuw=="], + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.18.1", "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.1.tgz", { "dependencies": { "vscode-jsonrpc": "9.0.0", "vscode-languageserver-types": "3.18.0" } }, "sha512-RTiiVHdpxpYcJVI5sq6S5TLjQ4WDR/rBrIWru+kPXe6sGQ9PFQ3GamrTKLvPqbR4ylr1SoodhmcqbFII0WXVuw=="], - "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "https://registry.npmmirror.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], - "vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "https://registry.npmmirror.com/vscode-languageserver-types/-/vscode-languageserver-types-3.18.0.tgz", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="], + "vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.0.tgz", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="], - "vscode-oniguruma": ["vscode-oniguruma@1.7.0", "https://registry.npmmirror.com/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz", {}, "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA=="], + "vscode-oniguruma": ["vscode-oniguruma@1.7.0", "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz", {}, "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA=="], - "vscode-textmate": ["vscode-textmate@9.3.2", "https://registry.npmmirror.com/vscode-textmate/-/vscode-textmate-9.3.2.tgz", {}, "sha512-n2uGbUcrjhUEBH16uGA0TvUfhWwliFZ1e3+pTjrkim1Mt7ydB41lV08aUvsi70OlzDWp6X7Bx3w/x3fAXIsN0Q=="], + "vscode-textmate": ["vscode-textmate@9.3.2", "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.2.tgz", {}, "sha512-n2uGbUcrjhUEBH16uGA0TvUfhWwliFZ1e3+pTjrkim1Mt7ydB41lV08aUvsi70OlzDWp6X7Bx3w/x3fAXIsN0Q=="], - "vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + "vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], - "w3c-keyname": ["w3c-keyname@2.2.8", "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], - "warning": ["warning@4.0.3", "https://registry.npmmirror.com/warning/-/warning-4.0.3.tgz", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="], + "warning": ["warning@4.0.3", "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="], - "web-tree-sitter": ["web-tree-sitter@0.26.9", "https://registry.npmmirror.com/web-tree-sitter/-/web-tree-sitter-0.26.9.tgz", {}, "sha512-YJwSHANl6XFgeEjB8nitgj0qZYt5gkIesJ4w2srS2wcLB4GUa4xcOkM0YaMsU6WNR53YVIkDSY7Ej4pf3IXtCA=="], + "web-tree-sitter": ["web-tree-sitter@0.26.9", "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.26.9.tgz", {}, "sha512-YJwSHANl6XFgeEjB8nitgj0qZYt5gkIesJ4w2srS2wcLB4GUa4xcOkM0YaMsU6WNR53YVIkDSY7Ej4pf3IXtCA=="], - "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], - "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], - "when-exit": ["when-exit@2.1.5", "https://registry.npmmirror.com/when-exit/-/when-exit-2.1.5.tgz", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="], + "when-exit": ["when-exit@2.1.5", "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="], - "which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which": ["which@2.0.2", "https://registry.npmjs.org/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "why-is-node-running": ["why-is-node-running@2.3.0", "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "word-wrap": ["word-wrap@1.2.5", "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "word-wrap": ["word-wrap@1.2.5", "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "wrap-ansi": ["wrap-ansi@9.0.2", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], - "ws": ["ws@8.21.3", "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "ws": ["ws@8.21.3", "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], - "y18n": ["y18n@5.0.8", "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "y18n": ["y18n@5.0.8", "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - "yallist": ["yallist@3.1.1", "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yallist": ["yallist@3.1.1", "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "yaml": ["yaml@2.9.0", "https://registry.npmmirror.com/yaml/-/yaml-2.9.0.tgz", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yaml": ["yaml@2.9.0", "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "yargs": ["yargs@18.0.0", "https://registry.npmmirror.com/yargs/-/yargs-18.0.0.tgz", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], + "yargs": ["yargs@18.0.0", "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], - "yargs-parser": ["yargs-parser@22.0.0", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-22.0.0.tgz", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + "yargs-parser": ["yargs-parser@22.0.0", "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], - "yarn": ["yarn@1.22.22", "https://registry.npmmirror.com/yarn/-/yarn-1.22.22.tgz", { "bin": { "yarn": "bin/yarn.js", "yarnpkg": "bin/yarn.js" } }, "sha512-prL3kGtyG7o9Z9Sv8IPfBNrWTDmXB4Qbes8A9rEzt6wkJV8mUvoirjU0Mp3GGAU06Y0XQyA3/2/RQFVuK7MTfg=="], + "yarn": ["yarn@1.22.22", "https://registry.npmjs.org/yarn/-/yarn-1.22.22.tgz", { "bin": { "yarn": "bin/yarn.js", "yarnpkg": "bin/yarn.js" } }, "sha512-prL3kGtyG7o9Z9Sv8IPfBNrWTDmXB4Qbes8A9rEzt6wkJV8mUvoirjU0Mp3GGAU06Y0XQyA3/2/RQFVuK7MTfg=="], - "yjs": ["yjs@13.6.31", "https://registry.npmmirror.com/yjs/-/yjs-13.6.31.tgz", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw=="], + "yjs": ["yjs@13.6.31", "https://registry.npmjs.org/yjs/-/yjs-13.6.31.tgz", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw=="], - "yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "yoctocolors": ["yoctocolors@2.1.2", "https://registry.npmmirror.com/yoctocolors/-/yoctocolors-2.1.2.tgz", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + "yoctocolors": ["yoctocolors@2.1.2", "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], - "zod": ["zod@4.4.3", "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod": ["zod@4.4.3", "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "zod-validation-error": ["zod-validation-error@4.0.2", "https://registry.npmmirror.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "zod-validation-error": ["zod-validation-error@4.0.2", "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], - "zustand": ["zustand@5.0.14", "https://registry.npmmirror.com/zustand/-/zustand-5.0.14.tgz", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], + "zustand": ["zustand@5.0.14", "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], - "zwitch": ["zwitch@2.0.4", "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "zwitch": ["zwitch@2.0.4", "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@base-ui/utils/reselect": ["reselect@5.2.0", "https://registry.npmmirror.com/reselect/-/reselect-5.2.0.tgz", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], + "@base-ui/utils/reselect": ["reselect@5.2.0", "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], - "@code-inspector/core/chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@code-inspector/core/chalk": ["chalk@4.1.2", "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@floating-ui/react/@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="], + "@floating-ui/react/@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="], - "@floating-ui/react/@floating-ui/utils": ["@floating-ui/utils@0.2.12", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + "@floating-ui/react/@floating-ui/utils": ["@floating-ui/utils@0.2.12", "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], - "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], + "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], - "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], + "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], - "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], - "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], - "@react-grab/cli/agent-install": ["agent-install@0.0.6", "https://registry.npmmirror.com/agent-install/-/agent-install-0.0.6.tgz", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-7NRMZ/ZDz2vHevQTgJsocBFpakB1/Wx5ip19YSJuj4VOXpraWztTerViNtdSyARKZT9e2yVwUUB5JXXCE7mNrA=="], + "@react-grab/cli/agent-install": ["agent-install@0.0.6", "https://registry.npmjs.org/agent-install/-/agent-install-0.0.6.tgz", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-7NRMZ/ZDz2vHevQTgJsocBFpakB1/Wx5ip19YSJuj4VOXpraWztTerViNtdSyARKZT9e2yVwUUB5JXXCE7mNrA=="], - "@reduxjs/toolkit/reselect": ["reselect@5.2.0", "https://registry.npmmirror.com/reselect/-/reselect-5.2.0.tgz", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], + "@reduxjs/toolkit/reselect": ["reselect@5.2.0", "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], - "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.1.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], - "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@vitest/browser/ws": ["ws@8.19.0", "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "@vitest/browser/ws": ["ws@8.19.0", "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], - "@vue/compiler-core/@babel/parser": ["@babel/parser@7.28.0", "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.0.tgz", { "dependencies": { "@babel/types": "^7.28.0" }, "bin": "./bin/babel-parser.js" }, "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g=="], + "@vue/compiler-core/@babel/parser": ["@babel/parser@7.28.0", "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", { "dependencies": { "@babel/types": "^7.28.0" }, "bin": "./bin/babel-parser.js" }, "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g=="], - "@vue/compiler-core/entities": ["entities@4.5.0", "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "@vue/compiler-core/entities": ["entities@4.5.0", "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "bun-types/@types/node": ["@types/node@24.10.1", "https://registry.npmmirror.com/@types/node/-/node-24.10.1.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + "bun-types/@types/node": ["@types/node@24.10.1", "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], - "chalk/supports-color": ["supports-color@7.2.0", "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "chalk/supports-color": ["supports-color@7.2.0", "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "concurrently/chalk": ["chalk@5.6.2", "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "concurrently/chalk": ["chalk@5.6.2", "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "conf/semver": ["semver@7.7.2", "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "conf/semver": ["semver@7.7.2", "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - "deslop-js/@oxc-project/types": ["@oxc-project/types@0.132.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.132.0.tgz", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="], + "deslop-js/@oxc-project/types": ["@oxc-project/types@0.132.0", "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="], - "eslint/ajv": ["ajv@6.15.0", "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "eslint/ajv": ["ajv@6.15.0", "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], - "eslint/escape-string-regexp": ["escape-string-regexp@4.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "eslint/escape-string-regexp": ["escape-string-regexp@4.0.0", "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint/glob-parent": ["glob-parent@6.0.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "eslint/glob-parent": ["glob-parent@6.0.2", "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "eslint/ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "eslint/ignore": ["ignore@5.3.2", "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "import-in-the-middle/acorn": ["acorn@8.15.0", "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "import-in-the-middle/acorn": ["acorn@8.15.0", "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], - "launch-ide/chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "launch-ide/chalk": ["chalk@4.1.2", "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "micromatch/picomatch": ["picomatch@2.3.2", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "micromatch/picomatch": ["picomatch@2.3.2", "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "monaco-editor/dompurify": ["dompurify@3.2.7", "https://registry.npmmirror.com/dompurify/-/dompurify-3.2.7.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], + "monaco-editor/dompurify": ["dompurify@3.2.7", "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], - "ora/chalk": ["chalk@5.6.2", "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "ora/chalk": ["chalk@5.6.2", "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "ora/string-width": ["string-width@8.2.1", "https://registry.npmmirror.com/string-width/-/string-width-8.2.1.tgz", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + "ora/string-width": ["string-width@8.2.1", "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], - "oxc-parser/@oxc-project/types": ["@oxc-project/types@0.132.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.132.0.tgz", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="], + "oxc-parser/@oxc-project/types": ["@oxc-project/types@0.132.0", "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="], - "oxlint-plugin-react-doctor/oxc-parser": ["oxc-parser@0.135.0", "https://registry.npmmirror.com/oxc-parser/-/oxc-parser-0.135.0.tgz", { "dependencies": { "@oxc-project/types": "^0.135.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.135.0", "@oxc-parser/binding-android-arm64": "0.135.0", "@oxc-parser/binding-darwin-arm64": "0.135.0", "@oxc-parser/binding-darwin-x64": "0.135.0", "@oxc-parser/binding-freebsd-x64": "0.135.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.135.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.135.0", "@oxc-parser/binding-linux-arm64-gnu": "0.135.0", "@oxc-parser/binding-linux-arm64-musl": "0.135.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.135.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.135.0", "@oxc-parser/binding-linux-riscv64-musl": "0.135.0", "@oxc-parser/binding-linux-s390x-gnu": "0.135.0", "@oxc-parser/binding-linux-x64-gnu": "0.135.0", "@oxc-parser/binding-linux-x64-musl": "0.135.0", "@oxc-parser/binding-openharmony-arm64": "0.135.0", "@oxc-parser/binding-wasm32-wasi": "0.135.0", "@oxc-parser/binding-win32-arm64-msvc": "0.135.0", "@oxc-parser/binding-win32-ia32-msvc": "0.135.0", "@oxc-parser/binding-win32-x64-msvc": "0.135.0" } }, "sha512-/DaPStu0s2zzNSRRniKyTPM6Z/o+DapOp2JYNKDL8AsgaBGPK2IdZyB87SQjVH+xeQPz+Qr9mrjglfkYgtbVRA=="], + "oxlint-plugin-react-doctor/oxc-parser": ["oxc-parser@0.135.0", "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.135.0.tgz", { "dependencies": { "@oxc-project/types": "^0.135.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.135.0", "@oxc-parser/binding-android-arm64": "0.135.0", "@oxc-parser/binding-darwin-arm64": "0.135.0", "@oxc-parser/binding-darwin-x64": "0.135.0", "@oxc-parser/binding-freebsd-x64": "0.135.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.135.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.135.0", "@oxc-parser/binding-linux-arm64-gnu": "0.135.0", "@oxc-parser/binding-linux-arm64-musl": "0.135.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.135.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.135.0", "@oxc-parser/binding-linux-riscv64-musl": "0.135.0", "@oxc-parser/binding-linux-s390x-gnu": "0.135.0", "@oxc-parser/binding-linux-x64-gnu": "0.135.0", "@oxc-parser/binding-linux-x64-musl": "0.135.0", "@oxc-parser/binding-openharmony-arm64": "0.135.0", "@oxc-parser/binding-wasm32-wasi": "0.135.0", "@oxc-parser/binding-win32-arm64-msvc": "0.135.0", "@oxc-parser/binding-win32-ia32-msvc": "0.135.0", "@oxc-parser/binding-win32-x64-msvc": "0.135.0" } }, "sha512-/DaPStu0s2zzNSRRniKyTPM6Z/o+DapOp2JYNKDL8AsgaBGPK2IdZyB87SQjVH+xeQPz+Qr9mrjglfkYgtbVRA=="], - "parse-entities/@types/unist": ["@types/unist@2.0.11", "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "postcss/nanoid": ["nanoid@3.3.11", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "postcss/nanoid": ["nanoid@3.3.11", "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "prop-types/react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "prop-types/react-is": ["react-is@16.13.1", "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "react-doctor/oxlint": ["oxlint@1.66.0", "https://registry.npmmirror.com/oxlint/-/oxlint-1.66.0.tgz", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.66.0", "@oxlint/binding-android-arm64": "1.66.0", "@oxlint/binding-darwin-arm64": "1.66.0", "@oxlint/binding-darwin-x64": "1.66.0", "@oxlint/binding-freebsd-x64": "1.66.0", "@oxlint/binding-linux-arm-gnueabihf": "1.66.0", "@oxlint/binding-linux-arm-musleabihf": "1.66.0", "@oxlint/binding-linux-arm64-gnu": "1.66.0", "@oxlint/binding-linux-arm64-musl": "1.66.0", "@oxlint/binding-linux-ppc64-gnu": "1.66.0", "@oxlint/binding-linux-riscv64-gnu": "1.66.0", "@oxlint/binding-linux-riscv64-musl": "1.66.0", "@oxlint/binding-linux-s390x-gnu": "1.66.0", "@oxlint/binding-linux-x64-gnu": "1.66.0", "@oxlint/binding-linux-x64-musl": "1.66.0", "@oxlint/binding-openharmony-arm64": "1.66.0", "@oxlint/binding-win32-arm64-msvc": "1.66.0", "@oxlint/binding-win32-ia32-msvc": "1.66.0", "@oxlint/binding-win32-x64-msvc": "1.66.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-N4LLxYLd94KEBqXDMDM5f+2PUpItTjDLreXe2Gn5KhjhCK4Qp2YUXaBi8Yu325ryOgKwt22m45fpD7nPOn69Yw=="], + "react-doctor/oxlint": ["oxlint@1.66.0", "https://registry.npmjs.org/oxlint/-/oxlint-1.66.0.tgz", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.66.0", "@oxlint/binding-android-arm64": "1.66.0", "@oxlint/binding-darwin-arm64": "1.66.0", "@oxlint/binding-darwin-x64": "1.66.0", "@oxlint/binding-freebsd-x64": "1.66.0", "@oxlint/binding-linux-arm-gnueabihf": "1.66.0", "@oxlint/binding-linux-arm-musleabihf": "1.66.0", "@oxlint/binding-linux-arm64-gnu": "1.66.0", "@oxlint/binding-linux-arm64-musl": "1.66.0", "@oxlint/binding-linux-ppc64-gnu": "1.66.0", "@oxlint/binding-linux-riscv64-gnu": "1.66.0", "@oxlint/binding-linux-riscv64-musl": "1.66.0", "@oxlint/binding-linux-s390x-gnu": "1.66.0", "@oxlint/binding-linux-x64-gnu": "1.66.0", "@oxlint/binding-linux-x64-musl": "1.66.0", "@oxlint/binding-openharmony-arm64": "1.66.0", "@oxlint/binding-win32-arm64-msvc": "1.66.0", "@oxlint/binding-win32-ia32-msvc": "1.66.0", "@oxlint/binding-win32-x64-msvc": "1.66.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-N4LLxYLd94KEBqXDMDM5f+2PUpItTjDLreXe2Gn5KhjhCK4Qp2YUXaBi8Yu325ryOgKwt22m45fpD7nPOn69Yw=="], - "react-pdf/pdfjs-dist": ["pdfjs-dist@5.4.296", "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.80" } }, "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q=="], + "react-pdf/pdfjs-dist": ["pdfjs-dist@5.4.296", "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.80" } }, "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q=="], - "recharts/immer": ["immer@10.2.0", "https://registry.npmmirror.com/immer/-/immer-10.2.0.tgz", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + "recharts/immer": ["immer@10.2.0", "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], - "rolldown/@oxc-project/types": ["@oxc-project/types@0.144.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.144.0.tgz", {}, "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg=="], + "rolldown/@oxc-project/types": ["@oxc-project/types@0.144.0", "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", {}, "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg=="], "tree-sitter-astro/tree-sitter-html": ["tree-sitter-html@github:tree-sitter/tree-sitter-html#73a3947", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "tree-sitter-tree-sitter-html-73a3947", "sha512-YcMXdvNATfH0BHxt7quyOjD8VOJx5q8EB9fquZo+shvipiF1ptkBAzYiHUA/Mrn8/E/xGwJXYkGG9qV/RdjvwA=="], - "tree-sitter-cpp/tree-sitter-c": ["tree-sitter-c@0.23.6", "https://registry.npmmirror.com/tree-sitter-c/-/tree-sitter-c-0.23.6.tgz", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-0dxXKznVyUA0s6PjNolJNs2yF87O5aL538A/eR6njA5oqX3C3vH4vnx3QdOKwuUdpKEcFdHuiDpRKLLCA/tjvQ=="], + "tree-sitter-cpp/tree-sitter-c": ["tree-sitter-c@0.23.6", "https://registry.npmjs.org/tree-sitter-c/-/tree-sitter-c-0.23.6.tgz", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-0dxXKznVyUA0s6PjNolJNs2yF87O5aL538A/eR6njA5oqX3C3vH4vnx3QdOKwuUdpKEcFdHuiDpRKLLCA/tjvQ=="], - "tree-sitter-elixir/node-addon-api": ["node-addon-api@7.1.1", "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + "tree-sitter-elixir/node-addon-api": ["node-addon-api@7.1.1", "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], - "tree-sitter-kotlin/node-addon-api": ["node-addon-api@7.1.1", "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + "tree-sitter-kotlin/node-addon-api": ["node-addon-api@7.1.1", "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], - "tree-sitter-objc/tree-sitter-c": ["tree-sitter-c@0.23.6", "https://registry.npmmirror.com/tree-sitter-c/-/tree-sitter-c-0.23.6.tgz", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-0dxXKznVyUA0s6PjNolJNs2yF87O5aL538A/eR6njA5oqX3C3vH4vnx3QdOKwuUdpKEcFdHuiDpRKLLCA/tjvQ=="], + "tree-sitter-objc/tree-sitter-c": ["tree-sitter-c@0.23.6", "https://registry.npmjs.org/tree-sitter-c/-/tree-sitter-c-0.23.6.tgz", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-0dxXKznVyUA0s6PjNolJNs2yF87O5aL538A/eR6njA5oqX3C3vH4vnx3QdOKwuUdpKEcFdHuiDpRKLLCA/tjvQ=="], - "tree-sitter-swift/tree-sitter-cli": ["tree-sitter-cli@0.23.2", "https://registry.npmmirror.com/tree-sitter-cli/-/tree-sitter-cli-0.23.2.tgz", { "bin": { "tree-sitter": "cli.js" } }, "sha512-kPPXprOqREX+C/FgUp2Qpt9jd0vSwn+hOgjzVv/7hapdoWpa+VeWId53rf4oNNd29ikheF12BYtGD/W90feMbA=="], + "tree-sitter-swift/tree-sitter-cli": ["tree-sitter-cli@0.23.2", "https://registry.npmjs.org/tree-sitter-cli/-/tree-sitter-cli-0.23.2.tgz", { "bin": { "tree-sitter": "cli.js" } }, "sha512-kPPXprOqREX+C/FgUp2Qpt9jd0vSwn+hOgjzVv/7hapdoWpa+VeWId53rf4oNNd29ikheF12BYtGD/W90feMbA=="], - "tree-sitter-typescript/tree-sitter-javascript": ["tree-sitter-javascript@0.23.1", "https://registry.npmmirror.com/tree-sitter-javascript/-/tree-sitter-javascript-0.23.1.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-/bnhbrTD9frUYHQTiYnPcxyHORIw157ERBa6dqzaKxvR/x3PC4Yzd+D1pZIMS6zNg2v3a8BZ0oK7jHqsQo9fWA=="], + "tree-sitter-typescript/tree-sitter-javascript": ["tree-sitter-javascript@0.23.1", "https://registry.npmjs.org/tree-sitter-javascript/-/tree-sitter-javascript-0.23.1.tgz", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-/bnhbrTD9frUYHQTiYnPcxyHORIw157ERBa6dqzaKxvR/x3PC4Yzd+D1pZIMS6zNg2v3a8BZ0oK7jHqsQo9fWA=="], - "unidiff/diff": ["diff@5.2.2", "https://registry.npmmirror.com/diff/-/diff-5.2.2.tgz", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], + "unidiff/diff": ["diff@5.2.2", "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], - "uvu/diff": ["diff@5.2.2", "https://registry.npmmirror.com/diff/-/diff-5.2.2.tgz", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], + "uvu/diff": ["diff@5.2.2", "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], - "uvu/kleur": ["kleur@4.1.5", "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "uvu/kleur": ["kleur@4.1.5", "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - "vitest/vite": ["vite@8.2.1", "https://registry.npmmirror.com/vite/-/vite-8.2.1.tgz", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="], + "vitest/vite": ["vite@8.2.1", "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="], - "vscode-languageserver/vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "https://registry.npmmirror.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + "vscode-languageserver/vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], - "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "@code-inspector/core/chalk/supports-color": ["supports-color@7.2.0", "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "@code-inspector/core/chalk/supports-color": ["supports-color@7.2.0", "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@floating-ui/react/@floating-ui/react-dom/@floating-ui/dom": ["@floating-ui/dom@1.8.0", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], + "@floating-ui/react/@floating-ui/react-dom/@floating-ui/dom": ["@floating-ui/dom@1.8.0", "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], - "@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], - "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], - "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], - "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - "@vue/compiler-core/@babel/parser/@babel/types": ["@babel/types@7.28.2", "https://registry.npmmirror.com/@babel/types/-/types-7.28.2.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], + "@vue/compiler-core/@babel/parser/@babel/types": ["@babel/types@7.28.2", "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], - "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "launch-ide/chalk/supports-color": ["supports-color@7.2.0", "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "launch-ide/chalk/supports-color": ["supports-color@7.2.0", "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.135.0.tgz", { "os": "android", "cpu": "arm" }, "sha512-sHeZItACNcA5WRAWqF6ixriR4GkZDyY10gVgnZU7pXku1DjHFATSqnwZM809jl0gXPHxb6fKzYQCK7bNK5cACQ=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.135.0.tgz", { "os": "android", "cpu": "arm" }, "sha512-sHeZItACNcA5WRAWqF6ixriR4GkZDyY10gVgnZU7pXku1DjHFATSqnwZM809jl0gXPHxb6fKzYQCK7bNK5cACQ=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.135.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-wPte+SzgzWWFgMSF8YZDNM+tBXtJg0AXBi7+tU3yS2z1f2Af9kRLZLKuJojADmuD/cZexmnMHHC3SDItTW77Iw=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.135.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-wPte+SzgzWWFgMSF8YZDNM+tBXtJg0AXBi7+tU3yS2z1f2Af9kRLZLKuJojADmuD/cZexmnMHHC3SDItTW77Iw=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.135.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-BmKz3lHIsqVos+9aPcdYCT9MG3APoUyM43KlEFhJMWNVDOGG8FKyiFz81Bc+mGz2o0hpuQ3PfXLfVWJrKXjo2g=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.135.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-BmKz3lHIsqVos+9aPcdYCT9MG3APoUyM43KlEFhJMWNVDOGG8FKyiFz81Bc+mGz2o0hpuQ3PfXLfVWJrKXjo2g=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.135.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-dM8BS+8+Br1fNvmh2QZbGiHaYttwLebRa6J4Uz9vuFzMNmvsdRYwf7993ptOaV0JTrR63AaoVLjX7nhWbijxjQ=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.135.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-dM8BS+8+Br1fNvmh2QZbGiHaYttwLebRa6J4Uz9vuFzMNmvsdRYwf7993ptOaV0JTrR63AaoVLjX7nhWbijxjQ=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.135.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-xlZnvvJdR9bGu2pOhvR5hMuKPHCE6Sa9owK5A484mzjHdm75VRV5nCs5w/jkmGODMMTFc+KN7EnZqEieM813kw=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.135.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-xlZnvvJdR9bGu2pOhvR5hMuKPHCE6Sa9owK5A484mzjHdm75VRV5nCs5w/jkmGODMMTFc+KN7EnZqEieM813kw=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.135.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-PSR8LmBK/H/PQRiN8g7RebQgZX/ntVCrdT/JBfNxE5ezdHG1s2i4rbazsRJYD83TTI1MmgTpC0MGL42PLtskQQ=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.135.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-PSR8LmBK/H/PQRiN8g7RebQgZX/ntVCrdT/JBfNxE5ezdHG1s2i4rbazsRJYD83TTI1MmgTpC0MGL42PLtskQQ=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.135.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-I85GJXzfUsigkkk7Ngdz95C217M4FdUi1Z2HrX5UyPmURobwQZ7m2bbUvwFkz4VGZd+lymFGKHvDZ3RQC9qOzA=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.135.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-I85GJXzfUsigkkk7Ngdz95C217M4FdUi1Z2HrX5UyPmURobwQZ7m2bbUvwFkz4VGZd+lymFGKHvDZ3RQC9qOzA=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.135.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-zqEY0npz0g0aGZj/8a5BclunjVDytsBQHYtIC10Gd26HcrLwbVF6YDbqRQjunMGYdSo97u6xOBl05aTDI2diDQ=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.135.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-zqEY0npz0g0aGZj/8a5BclunjVDytsBQHYtIC10Gd26HcrLwbVF6YDbqRQjunMGYdSo97u6xOBl05aTDI2diDQ=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.135.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-mWAfprP819gQ2qYst1RxgTI8b/z0b29OpoKfRflIXLHde2dZLihQD4g47Onuvtpo5GPIkMYPRlX9QoeZfs/GnQ=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.135.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-mWAfprP819gQ2qYst1RxgTI8b/z0b29OpoKfRflIXLHde2dZLihQD4g47Onuvtpo5GPIkMYPRlX9QoeZfs/GnQ=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.135.0.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-gri8c2AOmJKJwOux2KTHFBfUaXoJURuVMKhmKEi/2hTF55cQteTDV2XNfTiE5oCC+Tnem1Y4/MWzcyDadtsSag=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.135.0.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-gri8c2AOmJKJwOux2KTHFBfUaXoJURuVMKhmKEi/2hTF55cQteTDV2XNfTiE5oCC+Tnem1Y4/MWzcyDadtsSag=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.135.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-Y2tkupCG5wo0SxH2rMLG4d4Kmv6DaM3sBp+GuM5lox0S8Za6VxKgQrY2Mut088QQxKkEE89n/4CCCgmw2o0e3Q=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.135.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-Y2tkupCG5wo0SxH2rMLG4d4Kmv6DaM3sBp+GuM5lox0S8Za6VxKgQrY2Mut088QQxKkEE89n/4CCCgmw2o0e3Q=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.135.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-xDRJq6i6WTynjeP+ISbDpyH4p9BaJ0wuQcL0lCSDkt9qOXC9dmwpOu1VG/TlwmPI3KpYntmO9nJCuc3TMTsNBA=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.135.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-xDRJq6i6WTynjeP+ISbDpyH4p9BaJ0wuQcL0lCSDkt9qOXC9dmwpOu1VG/TlwmPI3KpYntmO9nJCuc3TMTsNBA=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.135.0.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-V4MoUuiCRNvihxhIufRxvK+ka013V4joTSK0FAGA1KEjLuNprfH6N/Qw2uxQEVIFuNYMhD/hV6xJ/ptbzlKdHg=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.135.0.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-V4MoUuiCRNvihxhIufRxvK+ka013V4joTSK0FAGA1KEjLuNprfH6N/Qw2uxQEVIFuNYMhD/hV6xJ/ptbzlKdHg=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.135.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-JCFZ7zM7KXOKoPAbK/ZB4wY0M1jxRECiem2UQuiXLjzGqS9+hno7mtX+qyK2F7HWK2xPhyJb+frpcOtk5DKOtg=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.135.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-JCFZ7zM7KXOKoPAbK/ZB4wY0M1jxRECiem2UQuiXLjzGqS9+hno7mtX+qyK2F7HWK2xPhyJb+frpcOtk5DKOtg=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.135.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-9jSVS1b3hOV7sdKH4aA2DFfnTz0RgQd0v2BefR+LYbH8yIlmSM22JJZbAAjVeVXmFgUAk3zJQ1tpE/Nd+Vi2YQ=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.135.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-9jSVS1b3hOV7sdKH4aA2DFfnTz0RgQd0v2BefR+LYbH8yIlmSM22JJZbAAjVeVXmFgUAk3zJQ1tpE/Nd+Vi2YQ=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.135.0.tgz", { "os": "none", "cpu": "arm64" }, "sha512-M857ZLBSdn1Uy/SJJz5zh0qGu67B4P9omCgXGBU2LLqTzraX6ZjVNaKq5yW1PDw/LgJXDXR/dbZfgmB310f11Q=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.135.0.tgz", { "os": "none", "cpu": "arm64" }, "sha512-M857ZLBSdn1Uy/SJJz5zh0qGu67B4P9omCgXGBU2LLqTzraX6ZjVNaKq5yW1PDw/LgJXDXR/dbZfgmB310f11Q=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.135.0.tgz", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-2w6DVcntQZX9U5RhXtgiWb3FLWFB5EcwI1U8yr3htOCJUJjagN4BFUHz/Y/d9ZsumndZ6ByxxWEtbUZNE1bfFw=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.135.0.tgz", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-2w6DVcntQZX9U5RhXtgiWb3FLWFB5EcwI1U8yr3htOCJUJjagN4BFUHz/Y/d9ZsumndZ6ByxxWEtbUZNE1bfFw=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.135.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-rX1U8+IH2Z37EJjDXKa1iifvUQAdba+vZ4Ewj1iaG5eA/QaSybzclCOwtWa0/5BuUQnnK/T2JHUEFrwhL6Ck2Q=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.135.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-rX1U8+IH2Z37EJjDXKa1iifvUQAdba+vZ4Ewj1iaG5eA/QaSybzclCOwtWa0/5BuUQnnK/T2JHUEFrwhL6Ck2Q=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.135.0.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-9FAisBbH1QICGAjlJobiuKGd/jOuVmyqniWdQMwTa5SkCl6hhuotBCJf1n46B0flYbSOR5TzfV9HZCWSyb3c/Q=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.135.0.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-9FAisBbH1QICGAjlJobiuKGd/jOuVmyqniWdQMwTa5SkCl6hhuotBCJf1n46B0flYbSOR5TzfV9HZCWSyb3c/Q=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.135.0", "https://registry.npmmirror.com/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.135.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-wYF+A2AzJ2n7ul6q+Z2G/ia0S2+8cUp0AgWZzoFvF4WmUcl1P7p+o6se1Gdr5wGnWuF0iAMIkGddrjCarNr2yA=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.135.0", "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.135.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-wYF+A2AzJ2n7ul6q+Z2G/ia0S2+8cUp0AgWZzoFvF4WmUcl1P7p+o6se1Gdr5wGnWuF0iAMIkGddrjCarNr2yA=="], - "oxlint-plugin-react-doctor/oxc-parser/@oxc-project/types": ["@oxc-project/types@0.135.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.135.0.tgz", {}, "sha512-wR+xRdFkUBMvcAjBJ2q2kcZM6d+DKu2NgoOyxZgYwZdLhmiv6+rnO8PZ/P68kMiZtIKm+pW7zyEJ4kSOs0vo+Q=="], + "oxlint-plugin-react-doctor/oxc-parser/@oxc-project/types": ["@oxc-project/types@0.135.0", "https://registry.npmjs.org/@oxc-project/types/-/types-0.135.0.tgz", {}, "sha512-wR+xRdFkUBMvcAjBJ2q2kcZM6d+DKu2NgoOyxZgYwZdLhmiv6+rnO8PZ/P68kMiZtIKm+pW7zyEJ4kSOs0vo+Q=="], - "react-doctor/oxlint/@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.66.0.tgz", { "os": "android", "cpu": "arm" }, "sha512-f7kq8N51T4phpzqfBpA2qaVTI/KrkCmNwaj3t/97I/WLTDI+UhlP5GL9eER+zVxBhtlx5rKXWByJU1/zDAvyaw=="], + "react-doctor/oxlint/@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.66.0", "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.66.0.tgz", { "os": "android", "cpu": "arm" }, "sha512-f7kq8N51T4phpzqfBpA2qaVTI/KrkCmNwaj3t/97I/WLTDI+UhlP5GL9eER+zVxBhtlx5rKXWByJU1/zDAvyaw=="], - "react-doctor/oxlint/@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-android-arm64/-/binding-android-arm64-1.66.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-xu6QO71tdDS9mjmLZ3AqhtaVHBvdmsOKkYnReNNDgh+XiwnsipeQOIxbiYOOO0iAXycJ+GK0wdMSZP/2j/AmSg=="], + "react-doctor/oxlint/@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.66.0", "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.66.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-xu6QO71tdDS9mjmLZ3AqhtaVHBvdmsOKkYnReNNDgh+XiwnsipeQOIxbiYOOO0iAXycJ+GK0wdMSZP/2j/AmSg=="], - "react-doctor/oxlint/@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.66.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-HZ24VimSOC7mxuEA99e0H2FS0C1yO3+iW13jPRAk+e2njsUs3QeAXsafCDyaIrV/MirdOVez+etQNQsJE43zNQ=="], + "react-doctor/oxlint/@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.66.0", "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.66.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-HZ24VimSOC7mxuEA99e0H2FS0C1yO3+iW13jPRAk+e2njsUs3QeAXsafCDyaIrV/MirdOVez+etQNQsJE43zNQ=="], - "react-doctor/oxlint/@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.66.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-awhj8ZvJrrRSnXj7V++rpZvTmnl99L6mi0B7gg7Cp7BN6cKpzuI481bHNLvXGA9GB1/oEgA3ponuyoAc6Md12A=="], + "react-doctor/oxlint/@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.66.0", "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.66.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-awhj8ZvJrrRSnXj7V++rpZvTmnl99L6mi0B7gg7Cp7BN6cKpzuI481bHNLvXGA9GB1/oEgA3ponuyoAc6Md12A=="], - "react-doctor/oxlint/@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.66.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-KQF0oVV21/FjIqkRuL8Q1vh8ECsE5+ocdH5tcqTQ4ZnYuDVoYibQUNfqBjQaUsP6UIIda5Y75Wpm5p4RgQWiWw=="], + "react-doctor/oxlint/@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.66.0", "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.66.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-KQF0oVV21/FjIqkRuL8Q1vh8ECsE5+ocdH5tcqTQ4ZnYuDVoYibQUNfqBjQaUsP6UIIda5Y75Wpm5p4RgQWiWw=="], - "react-doctor/oxlint/@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.66.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-9u1rgwZSEXWb30vbFZzQ78HVXBo0WCKNwJ3a2InRUTNMRng+PUDIoSFmA+m4HdUfBaIqftShq8J8qHc+eE/Vig=="], + "react-doctor/oxlint/@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.66.0", "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.66.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-9u1rgwZSEXWb30vbFZzQ78HVXBo0WCKNwJ3a2InRUTNMRng+PUDIoSFmA+m4HdUfBaIqftShq8J8qHc+eE/Vig=="], - "react-doctor/oxlint/@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.66.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-Ynot2HR1bHxUaNWoC280MVTDfZuaWuP3XfSMRDhyuZrVjhzoaBCVFlw8h8qeZjWKVUBhPWFIxB7AQTlK8Z2WWg=="], + "react-doctor/oxlint/@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.66.0", "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.66.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-Ynot2HR1bHxUaNWoC280MVTDfZuaWuP3XfSMRDhyuZrVjhzoaBCVFlw8h8qeZjWKVUBhPWFIxB7AQTlK8Z2WWg=="], - "react-doctor/oxlint/@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.66.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-xCbgzciGgo+A4aQZEknsNrNiIwY7sU5SfRuMmRjPIvZAgdF34cIHiKvwOsS5XRLjlTVSFwitmq6YclTtHTfU+g=="], + "react-doctor/oxlint/@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.66.0", "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.66.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-xCbgzciGgo+A4aQZEknsNrNiIwY7sU5SfRuMmRjPIvZAgdF34cIHiKvwOsS5XRLjlTVSFwitmq6YclTtHTfU+g=="], - "react-doctor/oxlint/@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.66.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-hmo+ZB/lHkR1HdDmnziNpzSLmulnUSu10VEqX2Yex7OwvoBAbjJQLvy4gIBRV3AAwWnCvAxKp5Nv1GE6LU1QMg=="], + "react-doctor/oxlint/@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.66.0", "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.66.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-hmo+ZB/lHkR1HdDmnziNpzSLmulnUSu10VEqX2Yex7OwvoBAbjJQLvy4gIBRV3AAwWnCvAxKp5Nv1GE6LU1QMg=="], - "react-doctor/oxlint/@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.66.0.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-2Invd4Uyy81mVooQC5FBtfxSNrvcX1OxbMlVQ6M2erRrNI2awFYF26YNW2yFxdVFZ4ffNOWKghtMjhnUPsXsVA=="], + "react-doctor/oxlint/@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.66.0", "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.66.0.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-2Invd4Uyy81mVooQC5FBtfxSNrvcX1OxbMlVQ6M2erRrNI2awFYF26YNW2yFxdVFZ4ffNOWKghtMjhnUPsXsVA=="], - "react-doctor/oxlint/@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.66.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-s0iXPDQVdgayE3RGa/N2DZF7tjgg0TwEtD1sGoDxqPDGrIXgo45H0yHknT0f9A0yteASsweYZtDyTuVlM4aSag=="], + "react-doctor/oxlint/@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.66.0", "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.66.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-s0iXPDQVdgayE3RGa/N2DZF7tjgg0TwEtD1sGoDxqPDGrIXgo45H0yHknT0f9A0yteASsweYZtDyTuVlM4aSag=="], - "react-doctor/oxlint/@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.66.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-OekL4XFiu7RPK0JIZi8VeHgtIXPREf42t8Cy/rKEsC+P3gcqDgNAAGiyuUOpdbG4wwbfue1q4CHcCO7spSve6w=="], + "react-doctor/oxlint/@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.66.0", "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.66.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-OekL4XFiu7RPK0JIZi8VeHgtIXPREf42t8Cy/rKEsC+P3gcqDgNAAGiyuUOpdbG4wwbfue1q4CHcCO7spSve6w=="], - "react-doctor/oxlint/@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.66.0.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-Ga1D0kj1SFslm34ThA/BdkUlyAYEnTsXyRC4pF0C5agZSwtGdHYWMTQWemUfBGp4RCG4QWXgdO+HmmmKqOtlBg=="], + "react-doctor/oxlint/@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.66.0", "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.66.0.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-Ga1D0kj1SFslm34ThA/BdkUlyAYEnTsXyRC4pF0C5agZSwtGdHYWMTQWemUfBGp4RCG4QWXgdO+HmmmKqOtlBg=="], - "react-doctor/oxlint/@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.66.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-p5jfP1wUZe/IC3qpQO84n9DRnf9g3lKRtLBlQq23ykyrDglHcVx7sWmVTlPuU6SBw8mNnPzyOn022G3XZHnlww=="], + "react-doctor/oxlint/@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.66.0", "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.66.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-p5jfP1wUZe/IC3qpQO84n9DRnf9g3lKRtLBlQq23ykyrDglHcVx7sWmVTlPuU6SBw8mNnPzyOn022G3XZHnlww=="], - "react-doctor/oxlint/@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.66.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-vUB/sYlYZorDL1ZD+o9mRv7zbsykrrFRtmgS6R8musZqLtrPRQn1gc1eGpuX+sfdccz42STl/AqldY6XRb2upQ=="], + "react-doctor/oxlint/@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.66.0", "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.66.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-vUB/sYlYZorDL1ZD+o9mRv7zbsykrrFRtmgS6R8musZqLtrPRQn1gc1eGpuX+sfdccz42STl/AqldY6XRb2upQ=="], - "react-doctor/oxlint/@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.66.0.tgz", { "os": "none", "cpu": "arm64" }, "sha512-yde+6p/F59xRkGR9H1HfngWRif1QRJjynZK349l+UI0H6w9hL3G8/AVaTHFyTtLVQ56qtNbX2/5Dc77n1ovnOg=="], + "react-doctor/oxlint/@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.66.0", "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.66.0.tgz", { "os": "none", "cpu": "arm64" }, "sha512-yde+6p/F59xRkGR9H1HfngWRif1QRJjynZK349l+UI0H6w9hL3G8/AVaTHFyTtLVQ56qtNbX2/5Dc77n1ovnOg=="], - "react-doctor/oxlint/@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.66.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-O9GLucgoTdmOrbBX+EjzNe7o/Ze5TFOvXcib6bzUOtBOmj6cV+zw18NgB+cGKAkDw1Pdqs8vGkfHbbsLuDtXWg=="], + "react-doctor/oxlint/@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.66.0", "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.66.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-O9GLucgoTdmOrbBX+EjzNe7o/Ze5TFOvXcib6bzUOtBOmj6cV+zw18NgB+cGKAkDw1Pdqs8vGkfHbbsLuDtXWg=="], - "react-doctor/oxlint/@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.66.0.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-m3Pjwc2MfTcom4E4gOv7DyuGyt7OfGNCbmqDHd+N7EzXmP+ppHuudm2NjcA3AjV5TSeGxaguVF4SbTKHe1USYA=="], + "react-doctor/oxlint/@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.66.0", "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.66.0.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-m3Pjwc2MfTcom4E4gOv7DyuGyt7OfGNCbmqDHd+N7EzXmP+ppHuudm2NjcA3AjV5TSeGxaguVF4SbTKHe1USYA=="], - "react-doctor/oxlint/@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.66.0", "https://registry.npmmirror.com/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.66.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-/DbBvw8UFBhja6PqudUjV4UtfsJr0Oa7jUjWVKB0g86lj/VwnPrkngn0sFql3c9RDA0O16dh7ozsXb6GjNAzBQ=="], + "react-doctor/oxlint/@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.66.0", "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.66.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-/DbBvw8UFBhja6PqudUjV4UtfsJr0Oa7jUjWVKB0g86lj/VwnPrkngn0sFql3c9RDA0O16dh7ozsXb6GjNAzBQ=="], - "react-pdf/pdfjs-dist/@napi-rs/canvas": ["@napi-rs/canvas@0.1.88", "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.88.tgz", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.88", "@napi-rs/canvas-darwin-arm64": "0.1.88", "@napi-rs/canvas-darwin-x64": "0.1.88", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.88", "@napi-rs/canvas-linux-arm64-gnu": "0.1.88", "@napi-rs/canvas-linux-arm64-musl": "0.1.88", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.88", "@napi-rs/canvas-linux-x64-gnu": "0.1.88", "@napi-rs/canvas-linux-x64-musl": "0.1.88", "@napi-rs/canvas-win32-arm64-msvc": "0.1.88", "@napi-rs/canvas-win32-x64-msvc": "0.1.88" } }, "sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg=="], + "react-pdf/pdfjs-dist/@napi-rs/canvas": ["@napi-rs/canvas@0.1.88", "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.88.tgz", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.88", "@napi-rs/canvas-darwin-arm64": "0.1.88", "@napi-rs/canvas-darwin-x64": "0.1.88", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.88", "@napi-rs/canvas-linux-arm64-gnu": "0.1.88", "@napi-rs/canvas-linux-arm64-musl": "0.1.88", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.88", "@napi-rs/canvas-linux-x64-gnu": "0.1.88", "@napi-rs/canvas-linux-x64-musl": "0.1.88", "@napi-rs/canvas-win32-arm64-msvc": "0.1.88", "@napi-rs/canvas-win32-x64-msvc": "0.1.88" } }, "sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg=="], - "vitest/vite/lightningcss": ["lightningcss@1.33.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.33.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + "vitest/vite/lightningcss": ["lightningcss@1.33.0", "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], - "vitest/vite/picomatch": ["picomatch@4.0.5", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "vitest/vite/picomatch": ["picomatch@4.0.5", "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], - "vitest/vite/postcss": ["postcss@8.5.26", "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + "vitest/vite/postcss": ["postcss@8.5.26", "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], - "vitest/vite/tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "vitest/vite/tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + "vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], - "vscode-languageserver/vscode-languageserver-protocol/vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "https://registry.npmmirror.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + "vscode-languageserver/vscode-languageserver-protocol/vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], - "@floating-ui/react/@floating-ui/react-dom/@floating-ui/dom/@floating-ui/core": ["@floating-ui/core@1.8.0", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], + "@floating-ui/react/@floating-ui/react-dom/@floating-ui/dom/@floating-ui/core": ["@floating-ui/core@1.8.0", "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], - "@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + "@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - "@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + "@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], - "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.88", "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.88.tgz", { "os": "android", "cpu": "arm64" }, "sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ=="], + "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.88", "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.88.tgz", { "os": "android", "cpu": "arm64" }, "sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ=="], - "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.88", "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.88.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg=="], + "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.88", "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.88.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg=="], - "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.88", "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.88.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg=="], + "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.88", "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.88.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg=="], - "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.88", "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.88.tgz", { "os": "linux", "cpu": "arm" }, "sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg=="], + "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.88", "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.88.tgz", { "os": "linux", "cpu": "arm" }, "sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg=="], - "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.88", "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.88.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg=="], + "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.88", "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.88.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg=="], - "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.88", "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.88.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA=="], + "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.88", "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.88.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA=="], - "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.88", "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.88.tgz", { "os": "linux", "cpu": "none" }, "sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw=="], + "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.88", "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.88.tgz", { "os": "linux", "cpu": "none" }, "sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw=="], - "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.88", "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.88.tgz", { "os": "linux", "cpu": "x64" }, "sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw=="], + "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.88", "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.88.tgz", { "os": "linux", "cpu": "x64" }, "sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw=="], - "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.88", "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.88.tgz", { "os": "linux", "cpu": "x64" }, "sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g=="], + "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.88", "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.88.tgz", { "os": "linux", "cpu": "x64" }, "sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g=="], - "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.88", "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.88.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA=="], + "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.88", "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.88.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA=="], - "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.88", "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.88.tgz", { "os": "win32", "cpu": "x64" }, "sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ=="], + "react-pdf/pdfjs-dist/@napi-rs/canvas/@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.88", "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.88.tgz", { "os": "win32", "cpu": "x64" }, "sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ=="], - "vitest/vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + "vitest/vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], - "vitest/vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + "vitest/vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], - "vitest/vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + "vitest/vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], - "vitest/vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + "vitest/vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], - "vitest/vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + "vitest/vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], - "vitest/vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + "vitest/vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], - "vitest/vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + "vitest/vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], - "vitest/vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + "vitest/vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], - "vitest/vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + "vitest/vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], - "vitest/vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + "vitest/vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], - "vitest/vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + "vitest/vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], - "vitest/vite/postcss/nanoid": ["nanoid@3.3.18", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], + "vitest/vite/postcss/nanoid": ["nanoid@3.3.18", "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], } } diff --git a/bunfig.toml b/bunfig.toml index f3fffb6b05..5582141ce7 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,3 +1,5 @@ [install] # Both platform packages resolve shared sources through one dependency graph. linker = "isolated" +# Keep new resolutions on the same official source as explicit bun.lock URLs. +registry = "https://registry.npmjs.org" diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 1df0811d6e..8ece4b9827 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -359,6 +359,32 @@ Swift 单元和插件 CI 通道复用已有 Cargo 下载缓存。先通过 ### 独立工作树的本地编译 +Windows 插件正式验收的默认、LSP、设置和文本模式均使用完整源码库存及 EXE/配置/目标身份;`-SkipBuild` 必须匹配构建时的全部输入,旧 marker 缺失库存时应重新构建,不能补写为可复用证据。 + +根工作区使用 Bun 1.3.14;`bunfig.toml` 与 `bun.lock` 的显式 tarball URL 均使用 +`https://registry.npmjs.org`。不要假定 `--registry` 会替换 lock 中的显式地址。 +仅迁移来源也会改变 lock 原字节,旧 Bun 缓存 seal 必须重新校验;版本、完整依赖图和 +SRI 保持不变。仍通过既有 `bun` 路由校验复制下载缓存,不复制 `node_modules` 或 +`.artifacts/bun-tmp`。两个 tree-sitter-cli 的 GitHub lifecycle 二进制不属于 npm +tarball SRI 的认证范围,尤其官方 digest 为 null 时不得登记为跨工作树二进制缓存。 +Bun 1.3.14 的官方修复覆盖 TLS 握手停滞及 isolated linker 下载/摘要错误后挂起; +这里只升级安装工具,产品依赖精确版本不变。Windows 路径过长时可在本次调用独占的 +空闲 SUBST 盘符运行同一工作树,结束后移除自己创建的映射,不修改系统 long-path +设置、不使用其他任务的盘符。工具候选的 npm metadata/tgz/EXE 和独立安装探针仅放 +ignored 诊断目录,不进入工作树下载缓存或资源复用清单。 + +Windows 安装 worker 为 tree-sitter-cli 的原始 `install.js` 加载专用代理适配器; +有 HTTPS 代理时要求 Node 22.21+ 或 24.5+,由 Node 的原生 `https.Agent({proxyEnv})` +负责 CONNECT/TLS。适配器只在该生命周期进程中移除上游手动代理分支读取的两个变量, +其它包与父进程环境不变,不禁用 TLS、SRI 或 lifecycle。无代理时沿用上游直连。 +代理变量按 Node 的 `https_proxy || HTTPS_PROXY` 选择;不支持的协议、非法 URL 或 +凭据编码会在变更前脱敏失败,修正配置后重试,不会静默改为直连。 +该适配器不下载、解压或缓存文件;输出仍归本次 Job-owned 安装状态,安装结束由既有 +流程封存允许复用的 Bun 下载缓存。运行时不写发行资源,不影响签名或增量更新基线。 +安装生命周期回归的 `/lithe Windows install */` 保存假工具、原生句柄等待器和 +进程身份凭据;每个 case 在对应子进程仍等待握手时保留其句柄,退出后关闭句柄、服务及 +连接才清理目录。这些临时生成物走 `frontend-install-state` 拒绝路由,所有阶段禁止复用。 + SwiftPM 的 `.build/`、资源 bundle 和 `.lithe-swift-toolchain` 标记登记为 `swiftpm-build-state`,所有工作树复用阶段均拒绝复制。编译器标记只用于本工作树 缓存失效,不是可转移的源码、平台、架构、SDK 和工具链完整性证明;只复用经过校验的 @@ -609,6 +635,21 @@ Inter 4.1 的 18 个静态 OTF(内部版本 4.001)及许可、JetBrains Mono 官方插件归档由 `.github/workflows/release-official-plugins.yml` 按插件独立 tag 发布, 不进入 app 构建或工作树缓存。 +- Windows 项目生成器归档事务:用户选择可写父目录后,原生事务独占创建同级 + `..lithe-creating-/`,仅在其内通过 `create_new` 写入 + `.lithe-project-archive-.zip`。每次受限 HTTPS 下载把重定向响应体计入同一个 + 128 MiB 预算、使用最长 120 秒的下载期限和同一取消 token;同步调用既有 Core + `project.extractArchive` 时保留原 operation ID 与操作剩余期限。Core 在写入前校验路径、 + 链接及显式/隐式目录预算,展开内容最多 512 MiB、20,000 个路径节点、64 层,累计路径 + UTF-8 字节最多 16 MiB。ZIP 描述符关闭后即删除;全部写入者结束、校验通过后才原子 + 不覆盖发布。失败或取消清理失败时保留 staging 与插件租约重试,无法确认写入者结束时 + 不能删除目录或声称清理完成。`excludedResources.project-generator-transactions` 在任何 + 工作树访问或复制前直接拒绝;HTTPS 内容没有固定摘要、版本、平台、架构或工具链 + identity stamp,不能复用、共享、软链接或打包。运行 + `node --test --test-timeout=60000 scripts/test-reuse-worktree-resources.mjs` 验证排除路由。 + 全程只写用户选择的目录,不改动安装资源、代码签名或 Sparkle delta 基线。 + capability 仍关闭,完整 Windows 原生及实机验证仍待完成。 + - 本地社区插件磁盘安装:`MacServiceContainer` 把 `fileStorage.temporaryDirectory()` 注入 `MacPluginDiskPackageImporter`,每次选择 ZIP 或解包目录后,先在该平台可写根下创建 私有的 `lithe-plugin-disk-/`(权限 `0700`)。ZIP 的 `archive.zip` 和复制/解压后的 @@ -769,7 +810,131 @@ Java Support(`dev.lithe.plugin.java-support`)是过渡期唯一随 Lithe.app 任何复制阶段都不得复用或共享。`excludedResources.plugin-web-state` 通过复用脚本的 排除路由直接拒绝,由 `scripts/test-reuse-worktree-resources.mjs` 验证,不能给它增加缓存校验器。 +Windows 的 Lithe 根映射保存在平台 adapter 返回的 +`/vcs-roots/.json`, +只在用户明确保存时创建;每个来源最多 1 MiB、64 条选择。它保留未知 JSON 字段和权限, +以原始字节版本拒绝外部冲突,复用设置 adapter 的同目录 `.tmp` 与 `.backup` 替换/恢复流程。 +工作区及选中根的原生目录身份防止相同路径的新目录继承旧选择;活动监听另按窗口、工作区登记、 +incarnation 与扫描代次隔离,关闭或插件撤销即释放。映射是 Lithe 自有偏好,不写 SVN/Git 配置。 +它们没有可复用的版本、平台、架构或工具链 stamp,`excludedResources.vcs-root-mappings` +在任何复制阶段都由脚本排除路由拒绝;临时文件、恢复备份和内存监听 lease 同样禁止共享。 +平台可写应用数据与只读安装资源分离,因此不影响代码签名或 Sparkle delta 基线。 + 如果后续新增可复用资源,必须同步更新注册表、校验器、脚本测试和本节说明。 +Windows 的插件源配置、按源与平台缓存的原始索引、摘要绑定的临时下载和按工作区保存的构建选项, +同属 `plugin-marketplace-state` 排除路由:由运行时平台 adapter 注入用户可写根, +取消或失败清理临时文件,持久配置由当前用户实例管理;没有可复用的版本/架构/工具链 identity, +不得在任何 worktree 复制阶段共享,也不写入发行资源或改变签名及增量更新基线。 +Windows 命令模型的 `/plugin-state//build--.model.json` +及依赖输出的同目录 `build--.dependencies.json` +按 `official-plugin-packages` 排除路由处理:由当前窗口、工作区代次和操作独占,确认后以 +`create_new` 创建,进程树清理完成后删除;清理失败时与原 owner 一起保留,崩溃遗留由插件状态/卸载流程回收。 +二者均无版本/平台/工具链复用身份,不跨工作树复制,不改变发行资源、签名或增量更新基线。 +显式 SVN move 诊断入口 `python -B scripts/plugin-host-acceptance/vcs_move_diagnostic.py --inputs --output ` 只接受固定 Provider、历史 SDK、已校验 VisualSVN ZIP/解压树和诊断 gate。它在 OS 临时目录创建 suite-owned runtime,以及每 case 独立的 repo/WC 和 profile;这些状态及显式 ignored 输出均走 `plugin-host-acceptance-state` 排除路由,不跨 worktree 复制。源码字节身份只证明本次诊断输入,不是可复用构建 stamp。Job 零成员证明成立后移除临时状态,保留诊断证据;清理未获证明时保留隔离目录并停止恢复读取,不承诺已清理。不写发行资源、不影响签名或 Sparkle delta。该入口不下载、不构建、不运行产品,不代表 move 准入。 + +ui:2 Web 回退夹具由 `web_bridge_fallback_fixture.py --output .artifacts/plugin-host-acceptance/web-bridge-fallback-fixture` 在目标工作树重新生成。静态 unsigned ZIP、工作区、source plan、私有页 CDP paused-request 决定、transport descriptor/回调 Map 观察及资源快照均属于 `plugin-host-acceptance-state`,没有可迁移的安装审批或可复用构建 identity;所有复用阶段拒绝,不新增下载校验路由。显式 `web_bridge_fallback_acceptance.py --help` 的执行入口消费同基线的隔离 x64 Release identity,使用 AppSession 保留句柄和本地 deadline,在失败路径释放 Fetch/CDP、退出产品并移除隔离 profile。CDP 仅令一次精确 IPC POST 失败;不改写 JS transport,不写发行镜像和已安装包,签名及增量更新基线不变。受控 ui:2 子集通过仍保留完整 M10、其他能力和平台 pending。 + +ui:1 字面文本验收由 `text_window_acceptance.py` 为本次已绑定端口的计数服务生成完整 unsigned API3 `requires.ui=1` 包。`text-window-fixture*` 的 ZIP、工作区、source plan,私有宿主文本页的 DOM/CSP、预加载请求、桥 descriptor/回调 Map 及计数器正向控制和零字面 URL 请求证据,均属于 `plugin-host-acceptance-state`;没有版本/平台/架构/工具链可复用 stamp,所有复制阶段拒绝,不新增下载校验器。计数服务、浏览器观察、产品与 profile 仅由本次调用所有,失败路径也有界收尾;证据随本地审查或 CI retention 回收。运行时不写发行镜像或插件安装包,不影响签名与增量更新;受控 x64 文本子集不代表签名完整包或完整 M10。 + +执行入口为 `pwsh -NoProfile -File scripts/test-windows-declarative-plugin-host.ps1 -TextWindow`,与 marketplace/LSP/settings 模式互斥。入口保存构建前后的完整源码库存,确认相同后写入隔离宿主 identity;`-SkipBuild -TextWindow` 只能消费已有且源码、配置、EXE 哈希均匹配的 identity,旧 marker 缺失库存时必须重建,不能事后补填。源码扫描共用 300 秒截止时间,运行驱动由外层 900 秒 deadline 约束。浏览器网络观察不能计数原生 postMessage IPC,报告明确保留该限制,不能把回调排空解释为精确零 IPC。R19 仅完成工具及离线失败边界,lane 构建超时且未产出验收 EXE,真实产品步骤仍 pending。 + +正式 Windows 构建与插件宿主验收入口接受 `-NativeBunPath `; +该参数只选择已有的原生可执行文件,不安装 Bun。入口先检查其版本与目标工作树的 +`packageManager` 固定版本一致,再把所在目录加入继承的 PATH,确保 Node 资源校验器及 +安装子进程找到原生 Bun;PowerShell/npm shim 本身不能作为 Node 的执行目标。 +`-InstallTimeoutSeconds` 默认为 300,范围为 1~1200 秒,逐层传到既有安装 worker; +这是每次安装尝试的时限,缓存失败后的既有无缓存重试各自使用该值,不能当作整个构建时限。 + +Tauri 构建包装器保留原始 `Cargo.toml` 字节。动作结束后,只有内容恰好等于原始字节的 +CRLF→LF 转换才允许恢复;比较和恢复使用同一个独占文件句柄,未改动的字节和写入时间不变。 +其它语义改动、删除或被并发进程锁定的文件保持现状并使构建失败,不能重新创建或覆盖它们; +若构建动作和恢复检查同时失败,两条诊断均保留。该边界不允许以 TOML 等价或文本摘要 +代替构建前后源码的精确字节身份。 + +离线准备回归 `build_preflight_test.py` 使用 `/lithe-build-preflight-*/` 的一次性 +副本、伪 Bun/IPC 子进程、可变 manifest 和记录 stub;不调用 Cargo、Tauri 或产品。 +JDTLS 解压回归还在同一临时树生成小型 tar.gz/VSIX、前置 PATH 的假 tar 和提取产物, +全部保持一次性 owner,拒绝跨 worktree 复用。Windows JDTLS 准备直接从平台 +`[Environment]::SystemDirectory` 选择原生 tar,不依赖 MSYS/GNU tar 的 PATH 顺序; +缺少原生工具时在缓存或输出变更前失败,解压非零立即保留原生错误并停止后续复制。 +测试套件使用 180 秒预算,CI 与正式验收入口另有 190 秒的既有保留句柄进程 owner; +单项等待仍各自有界,超时按原进程身份回收。报告为 +`.artifacts/test-stability/plugin-build-preflight.{json,html,junit.xml}`, +临时树和伪工具按 `plugin-host-acceptance-state` 排除路由拒绝所有 worktree 复制, +没有可复用的版本、平台、架构或工具链 stamp,退出时清理本次资源。 +这些离线检查只能证明准备流程和失败边界,真实 `ui:1` 产品步骤仍须另外验收。 + +原生插件验收的 `.artifacts/plugin-host-acceptance/`(含市场 ZIP/索引、构建夹具的包、工具、工作区及 plan、`settings-functions-fixture*` 的包/工作区/身份 plan、设置与函数验收资源快照、隔离运行镜像和报告)及 +`app.lithe.windows.host-acceptance` 用户配置走 `plugin-host-acceptance-state` 排除路由: +每次在目标工作树重新生成,无可复用 identity,不跨 worktree 复制;驱动回收监听器、进程和隔离用户数据, +报告留在本次验证目录。HTTPS 使用已有可信证书,不复制私钥或改系统信任,也不改发行资源及签名基线。 +设置/函数入口先运行 `python -B scripts/plugin-host-acceptance/settings_functions_fixture.py --output .artifacts/plugin-host-acceptance/settings-functions-fixture`, +再用 `settings_functions_acceptance.py --fixture-root <该目录> --report <报告路径>` 做离线预检(pending 返回 2,输入错误返回 1,不启动产品)。 +真实执行由已有 `test-windows-declarative-plugin-host.ps1 -SkipBuild -SettingsFunctions -SettingsFunctionsFixtureRoot <该目录>` 消费隔离宿主 identity; +plan、当前 checkout 和隔离宿主的提交标识必须一致;切换提交后重新生成输入,不得清理该输入目录后冒用旧 plan。 +`-SkipBuild` 同时检查目标架构、已冻结配置和可执行文件哈希,并保留该配置;配置或提交变化时重新构建隔离宿主。驱动先恢复原生 invoke 并有界等待已观测调用结束,再退出本次产品进程,最后比较资源快照;退出失败或调用未结束不能报告资源工作流通过。 +驱动在插件详情验证普通设置,再经已注册的语言设置页保存 reload;受控设置子集通过不会把缺少兼容签名包、关闭中的 Provider 能力、arm64 或人工视觉验收算作通过。 +原生动作组、表单栅格和树菜单的受控浏览器回归使用目标工作树的前端依赖和 Python Playwright 1.60.0 +(与原生验收驱动共用 `psutil==7.2.2`),不由 runner 安装或下载工具。Windows CI 在已有 +Python 依赖步骤后独立运行以下命令;本地已有 Edge 时可复用相同入口: + +```powershell +$playwrightModule = python -c "from pathlib import Path; import playwright; print(Path(playwright.__file__).parent / 'driver/package/index.mjs')" +if ($LASTEXITCODE -ne 0) { throw 'Cannot resolve the configured Playwright driver' } +node scripts/test-windows-native-plugin-ui-browser.mjs --browser msedge --playwright-module $playwrightModule --timeout-ms 120000 --report .artifacts/test-stability/native-panel-browser.json +if ($LASTEXITCODE -ne 0) { throw 'Action-group browser regression failed' } +node scripts/test-windows-native-plugin-ui-browser.mjs --mode native-tree --browser msedge --playwright-module $playwrightModule --timeout-ms 120000 --report .artifacts/test-stability/native-tree-browser.json +if ($LASTEXITCODE -ne 0) { throw 'Native-tree browser regression failed' } +node scripts/test-windows-native-plugin-ui-browser.mjs --mode form-grid --browser msedge --playwright-module $playwrightModule --timeout-ms 120000 --report .artifacts/test-stability/native-form-grid-browser.json +if ($LASTEXITCODE -ne 0) { throw 'Form-grid browser regression failed' } +node scripts/test-windows-native-plugin-ui-browser.mjs --mode controls-input --browser msedge --playwright-module $playwrightModule --timeout-ms 120000 --report .artifacts/test-stability/native-controls-input-browser.json +if ($LASTEXITCODE -ne 0) { throw 'Controls-input browser regression failed' } +node scripts/test-windows-native-plugin-ui-browser.mjs --mode workbench --browser msedge --playwright-module $playwrightModule --timeout-ms 120000 --report .artifacts/test-stability/native-workbench-browser.json +if ($LASTEXITCODE -ne 0) { throw 'Workbench browser regression failed' } +foreach ($failure in @('worker-exit', 'worker-hang')) { + node scripts/test-windows-native-plugin-ui-browser.mjs --mode lifecycle --inject-failure $failure --timeout-ms 25000 --report ".artifacts/test-stability/native-browser-$failure.json" + if ($LASTEXITCODE -ne 0) { throw "Lifecycle regression failed: $failure" } +} +``` + +动作组、栅格和树模式加载生产样式及真实 React 组件,使用官方 Tauri mocks;动作组检查六阶段 +几何、状态、焦点、禁用提示与单次完整值提交,树模式由 Playwright 的浏览器键盘输入 +检查菜单触发、导航、关闭、焦点和一次性动作。栅格模式固定浏览器 viewport,只调整夹具容器, +检查 12 列/span 在 639px、640px 和宽容器的几何、同一 DOM、草稿、焦点、禁用说明、公共 +readOnly 原语及完整 values 单次提交;不把 typed port 的可选 selectedID 当作下游 JSON null。 +控件输入模式在同一完整夹具上验证 select、checkbox、search/clear、editor、segmented、stepper +的实际浏览器交互、按原 onCommit 语义自动提交或仅保留草稿、禁用组拒绝输入和最终完整 values。 +所有模式均通过公共控件的可访问角色定位实际编辑节点,不把隐藏校验 input 当成用户输入。 +工作台模式复用真实 MainLayout、活动入口、公共控件及三个既有槽,连贯检查打开、输入、树动作、 +替换、移动、隐藏重开、工作区切换、迟到准备取消和关闭清理。原生 I/O、审批、终端内容及外部包/ +窗口身份仍为受控边界;单页重建不能作为两个产品窗口验收。工作台报告与日志也属于不可复用资源。 +五个组件模式各有 120 秒整体时限; +生命周期模式不加载 Vite 或浏览器,用提前退出和挂起的受控 worker 验证独立 owner 的清理, +25 秒整体时限中保留 20 秒收尾。Windows CI 的组合步骤另有 12 分钟外层上限。 +各模式成功或失败都生成独立 JSON、HTML 和 JUnit,不代表产品 WebView2、物理键盘、IME +或 `ui:3` 准入。 +生命周期的预期红报告留在报告父目录的 `expected-failures/` 子目录,普通报告只记录 +实际验证失败分类、子进程启动、Job 零成员证明和临时目录清理的正向断言; +不能把预期红报告混入顶层通过总数,也不能把一次注入失败解释为产品验收通过。 +每次调用独占 `.artifacts/native-browser-*/` 下的 `profile/`、Vite `cache/`、 +`worker-evidence.json`、`job-result.json` 和 `worker.log`;退出后把 worker 日志复制到 +报告同名的 `.worker.log`,便于 scratch 删除后诊断。进程先受 Windows Job Object +约束再执行,整体时限包含清理预留。只有进程树终止得到证明后才能删除 scratch, +无证明则失败并保留隔离目录。报告由本地审查或 CI retention 回收。 +这些路径及显式报告没有版本、平台、架构或工具链构建 stamp, +`excludedResources.native-browser-regression-state` 通过复用脚本的排除路由在任何复制阶段拒绝, +在目标工作树重新生成,不能共享浏览器 profile、优化缓存或进程所有权。 +所有写入都在本次工作树的忽略目录,不写安装包、发行资源或签名和 Sparkle delta 基线。 + +Windows 项目生成器的 `/..lithe-creating-/` 暂存及最终用户项目 +走 `plugin-generator-transactions` 排除路由:由当前窗口和原生操作独占,先在用户选择的目标旁生成, +验证后原子不覆盖发布;取消或失败清理同一 staging,清理失败保留操作与 NativeLease 供重试。 +没有版本/平台/架构/工具链复用身份,任何复制阶段均拒绝;不写插件包或发行资源,不影响签名与更新基线。 +Windows 插件设置的 `/plugin-settings/.json`、工作区 +`.lithe/plugin-settings.json` 及同源 `.json.-.tmp/.backup` +走 `plugin-settings-state` 排除路由。真实偏好由当前用户持有,保存事务持有临时文件和恢复备份; +打开只读,确认保存时比较原始字节、保留无关值与权限、原子替换并重读。失败清除自己的临时文件, +恢复不能确认时保留唯一备份并提示恢复。没有构建 identity,不能跨工作树共享;不写发行目录、签名或更新基线。 生成资源只有在构建流程写入可验证的源码、配置、平台、架构和工具链 identity stamp 后才能加入注册表,不能仅凭目录存在就跨 worktree 复制。 diff --git a/docs/development/windows-plugin-acceptance.md b/docs/development/windows-plugin-acceptance.md new file mode 100644 index 0000000000..dfcca5c908 --- /dev/null +++ b/docs/development/windows-plugin-acceptance.md @@ -0,0 +1,111 @@ +# Windows 插件真实包验收矩阵(M10 准备) + +状态:验收准备资料,不是验收记录。本文按 +[Windows 插件功能对齐计划](windows-plugin-parity-plan.md) M10 的要求,固定真实 Windows 插件包的身份、来源与逐项可复现验收步骤,并区分第一阶段(M1–M6)与第二阶段(M7–M9)出口。**本轮没有执行任何一次完整验收,也没有任何一行可以据此宣告 M10 完成。** + +数据核实:2026-10-24(首次)与 2026-10-25(能力声明/矩阵维度复核,并同步 preview `83017afa`),gh API(仓库元数据、release 资产、`Lithe-IDEA/Lithe-store/lithe-plugin-index.json`、`Lithe-IDEA/lithe-plugin-docs`)+ 主仓库基线 commit `1e78b59b6385fca6a881639b42048bf3bc587f4a` 的源码。索引与 release 会持续变化,每次执行验收前必须重新核实并把当时的 tag/版本/SHA-256 写进当次记录,不得沿用本文。 + +## 1. 通用验收流程(每个真实包逐项执行) + +以下链条来自路线图 M10 与 Windows 声明式插件宿主 Note 的既有边界,每个包按适用子流程裁剪,但裁剪必须在记录里写明。 + +| # | 步骤 | 通过标准 | 记录要求 | +| --- | --- | --- | --- | +| 1 | 前置固定 | 宿主构建 SHA、包 tag/commit、版本、下载 SHA-256、架构、构建配置(Release)全部登记 | 命令与输出 | +| 2 | 安装 | 市场下载按索引 SHA-256 核对(`plugin.archive.checksumMismatch` 拒绝篡改);磁盘导入走私有快照;staging→发布事务完成 | 安装来源字段与内容摘要 | +| 3 | 授权 | installReview 全量审批:权限 + 完整程序声明逐项可展开;社区包明示"未认证发布者";新增程序(newPrograms)高亮 | 审批界面截图 + 审批记录持久化确认 | +| 4 | 启用与重启 | 启用后重启应用,启动扫描结果一致;停用状态持久化;bundled 包仍为最高版本 | 扫描前后 catalog 对比 | +| 5 | 真实工作流 | 打开目标项目 → 语言服务启动/构建模型 → 运行、测试 → 取消一次在途操作;操作在真实 UI 触发,非仅命令调用 | 每步入口(菜单/按钮)、预期/实际 | +| 6 | 升级失败 | 更新时篡改摘要或中断下载:旧版本与旧安装记录保持可用,本次 staging 被清理,无"新目录配旧记录" | 失败注入方式与恢复证据 | +| 7 | 回滚 | 回滚到上一版本:先停止读用旧包的会话与页面,再换目录;回滚包按自身审批记录复验 | 页面关闭与进程停止证据 | +| 8 | 卸载 | 包目录、安装记录、所属页面、相关进程全部消失;无半删目录 | 卸载后目录与进程列表 | +| 9 | 进程树清理 | 每步结束后按包入口路径核对进程列表:语言服务器、Provider、构建子进程、辅助探测进程为零残留(Job Object 语义,非仅杀父 PID) | 进程快照(启动前/每步后) | +| 10 | 发行资源哈希不变 | 安装目录(含 `resource_dir()/OfficialPlugins` 等 bundled 资源)在验收前后的文件清单与哈希完全一致;运行时状态只出现在平台可写目录 | 前后哈希清单 diff | +| 11 | 证据归档 | 按路线图 §5.4:版本三元组、入口、复现步骤、预期/实际、测试命令与单测计时、哈希清单、进程清理、已知范围;截图先脱敏 | 存放位置与索引 | + +架构要求:`windows-x64` 与 `windows-arm64` 分别记录。没有 arm64 实机或可验证执行环境时保留 pending——**编译成功或 x64 仿真不替代 arm64 原生运行证据**。构建脚本已有 `aarch64-pc-windows-msvc` 目标标识(`scripts/build-windows.ps1`),但目前没有已发布的 arm64 工件与实机记录。 + +## 2. 真实包矩阵 + +"当前可得"列描述在本轮核实时点的可安装性。全部社区包当前只有 `darwin-*` 工件;**没有任何 windows-\* 工件存在**,这是第二列之外最大的统一 pending。 + +| 包 | 来源与版本(核实于 2026-10-24) | 许可 | Windows 入口(x64/arm64) | 外部 runtime | 当前可得 / 缺失 | 负责阶段 | +| --- | --- | --- | --- | --- | --- | --- | +| PhpSupportContract(bundled 契约包) | 主仓库 `Plugins/win/Official/PhpSupportContract`,版本 1.0.0,api2,`sameTeamAsHost`(基线 1e78b59b) | 仓库内 Lithe 资源;`intelephense.js` 为 65 字节占位入口 | runtime launch:node 启动 `language-server/bin/intelephense.js`;无 `${arch}` 路径,两架构同入口 | Node.js(宿主发现);`php` CLI(运行/测试 executable) | 已随宿主安装链验收(validate/launch/runPlan/testPlan/resolveSettings 端到端测试 `tests.rs:433`);真实 Intelephense 替换 pending——其许可禁止再分发(见 `2026-10-20-go-php-community-plugins` Note),Windows 分发方式需另行决定 | M10 第一阶段(完整实机链) | +| io.github.lithe-idea.php-support | [lithe-plugin-php](https://github.com/Lithe-IDEA/lithe-plugin-php) tag `v0.1.0`,api1 | Apache-2.0(仓库 license) | systemCommand `intelephense --stdio`;工具架构随用户安装 | Node.js(runtimeDependencies)+ 用户自装 intelephense(npm,许可禁止打包) | macOS 可装;**windows 工件 pending**;官方索引/商店均未列 windows 平台 | M2(市场安装链)→ M10 第一阶段 | +| io.github.lithe-idea.go-support | [lithe-plugin-go](https://github.com/Lithe-IDEA/lithe-plugin-go) tag `v0.1.0`,api1 | Apache-2.0 | systemCommand `gopls`;go 工具链架构随系统 | Go 工具链(runtimeDependencies,宿主发现) | macOS 可装;**windows 工件 pending** | M2 → M10 第一阶段 | +| io.github.lithe-idea.lua-support | [lithe-plugin-lua](https://github.com/Lithe-IDEA/lithe-plugin-lua) tag `v0.3.0`(另有 0.1.0/0.2.0),api1 | Apache-2.0 | packaged `bin/lithe-lua-language-server`——**架构相关二进制**,Windows 需按 x64/arm64 分别出工件 | 无(服务器自带) | macOS 可装(0.3.0 起分 darwin-arm64/darwin-x64 两个资产);**windows 工件 pending** | M2 → M10 第一阶段 | +| io.github.lithe-idea.gradle-build | [lithe-plugin-gradle](https://github.com/Lithe-IDEA/lithe-plugin-gradle) tag `v0.1.1`,api3,`requires {buildSystems:1}`,模型源 command | Apache-2.0 | 声明式构建工具窗口 + 命令计划;入口依赖构建工具(Gradle wrapper/gradle) | Gradle(项目 wrapper 或 PATH) | macOS 可装;Windows 当前 `apiUnsupported`(宿主未声明 buildSystems);**windows 工件 pending** | M4 开放能力 → M10 第一阶段 | +| io.github.lithe-idea.maven-build | [lithe-plugin-maven](https://github.com/Lithe-IDEA/lithe-plugin-maven) tag `v0.1.1`,api3,`requires {buildProviders:1, buildSystems:1, serverProcess:1}`,模型源 provider | Apache-2.0 | Provider 进程(自有 launch)+ Core litheBuild 协商 | JDK(provider 进程与 Maven 工具链) | macOS 可装:宿主能力表声明 `buildProviders:[1]`、`buildSystems:[1,2]`、`serverProcess:[1]`(`PluginTypes.swift:492-503`),Core `SUPPORTED_CAPABILITIES` 同表;矩阵 `build-provider-refresh-and-tool` 记 macOS implemented/验证 pending(`MacBuildProviderSessionsTests` 覆盖会话自动化)——implemented 不等于已验证;Windows `apiUnsupported`(能力未开放);**windows 工件 pending** | Windows 侧 M5 开放能力 → M10 第一阶段(Provider/函数线);macOS 侧验证对照随 M10 第一阶段 | +| io.github.lithe-idea.cargo-build | [lithe-plugin-cargo](https://github.com/Lithe-IDEA/lithe-plugin-cargo) tag `v0.1.1`,api3,`requires {buildProviders:1, buildSystems:2, serverProcess:1, settings:2}`,模型源 provider;0.1.0 为 `{buildSystems:1, buildProviders:1}` | Apache-2.0 | Provider 进程;cargo 工具链 | Rust/Cargo 工具链 | 0.1.1 的 `buildSystems:2` 由 Core(`capabilities.rs` `SUPPORTED_CAPABILITIES`)与 macOS 宿主能力表(`buildSystems:[1,2]`)共同支持,macOS 可装(矩阵 `cargo-build-plugin` 记 macOS implemented/验证 pending);Windows `apiUnsupported`(buildSystems/buildProviders 未声明);**windows 工件 pending** | Windows 侧 M4/M5 开放能力(路线图 M4 明确含 `buildSystems:2` 兼容输入解析)→ M10 第一阶段 | +| io.github.lithe-idea.svn-support | [lithe-plugin-svn](https://github.com/Lithe-IDEA/lithe-plugin-svn) tag `v0.1.0`,api3,`requires {vcsProviders:1, serverProcess:1}`,vcsTool svn | Apache-2.0 | VCS Provider 进程会话 | svn 命令行客户端(用户提供) | macOS 可装:宿主已声明 `vcsProviders:[1]`(`PluginTypes.swift:502`),Core 支持;矩阵 `vcs-subversion-and-plugin-providers` 记 macOS partial/验证 pending(`VcsProviderHostTests` 覆盖根识别、元数据监听与生命周期自动化;svn 解析与错误码由其插件仓库 CI 经 Core 桥接在 file:// 仓库验证);Windows `apiUnsupported`;**windows 工件 pending** | M9 → M10 第二阶段 | +| Java Support(dev.lithe.plugin.java-support) | macOS bundled 官方包;JDT LS 上游固定 [v1.61.0](https://github.com/eclipse-jdtls/eclipse.jdt.ls/tree/v1.61.0);包内含 `LICENSE-EPL-2.0.txt`(组装脚本 `assemble-package.sh` 随包携带) | JDT LS 及 Eclipse 扩展为 EPL-2.0(随包 LICENSE 文件);icons 目录 Apache-2.0 | Windows 无分发:路线图 M8 明确必须满足其整份 `requires`,不得复用 macOS ZIP(含 macOS 架构 JDK/runtime) | 打包 JDK(packagedRuntime,`${arch}` 路径) | macOS bundled 可用;**Windows 分发 pending(构建/打包决策未做)** | M8(生成器/分发)+ M10 第一阶段(语言链验收) | +| 官方可下载插件渠道 | `Plugins/marketplace/index.json` 与 `official-plugins.json` 均为空;发布管线 `release-official-plugins.yml` 存在(macos-26 runner 构建、tag 系列 ``) | 按未来包而定 | 官方索引当前无条目,无可验收的官方 Windows 包 | — | 渠道就绪但**无条目 → pending**;第一个官方包发布后按 §1 全链验收 | M2(索引/下载)→ M10 第一阶段 | +| 原生 UI 真实示例(ui:3) | 未选定外部示例仓库与版本 | 待选型 | native schema 树/列表/表单渲染 | 待选型 | **pending 选型**;路线图要求"固定版本的外部示例" | M7 → M10 第二阶段 | +| 项目生成器真实包(projectGenerators) | 未选定(`project-generator` fixture 仅覆盖 Core 语义;JavaSupport 模板为 macOS 分发) | 待选型 | 新建项目窗口 + files/command/archive 步骤 | 待选型 | **pending 选型** | M8 → M10 第二阶段 | + +补充说明: + +- 商店索引当前 8 个插件、13 个版本全部只列 `darwin-arm64`/`darwin-x64` 下载(gh 复核 2026-10-25,`lithe-plugin-index.json`)。Windows 可用性在这些包发布 windows-\* 工件并更新索引之前,一律按 `plugin.index.platformUnsupported` 展示——这是契约定义的诚实状态,不能通过放宽校验绕过。 +- 索引新增的 `io.github.lithe-idea.database 0.1.0` 声明 `requires {backend:1, serverProcess:1, ui:2}`:本审计基线 `1e78b59b` 的 Core 未定义 `backend` capability;preview 已在 `8471b998`(#1470 数据库插件线)新增 `backend:[1]` 且 macOS 宿主能力表已声明(`PluginTypes.swift:493`),Windows 宿主对 backend 零引用。本 PR 已同步 preview,`fixtures/backend.plugin.json` 已随同步登记进覆盖清单。该插件属于本轮 M0–M10 冻结范围之外的新能力,这里只记录事实,不列为验收项。 +- 宿主能力与 Core 支持版本的事实来源:`macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift:492-504`(`PluginManifest.hostCapabilities`:backend:[1]、buildProviders:[1]、buildSystems:[1,2]、executableResolution:[1]、executionProviders:[1]、projectGenerators:[1]、serverProcess:[1]、settings:[1,2]、ui:[1,2,3]、vcsProviders:[1]、languages:[1]、languageServers:[1,2])与 `rust/lithe-core/src/plugins/capabilities.rs`(`SUPPORTED_CAPABILITIES`)。注意 `shared/contracts/plugins/v1/README.md` 能力一节仍写 macOS 不声明 buildProviders/vcsProviders(preview 上也保留),已落后于代码,以代码与功能矩阵为准(过时描述已记录在 fixture 覆盖审计 §5)。 + +## 3. 分包类验收步骤(§1 通用链的裁剪点) + +### 3.1 语言插件(php-support / go-support / lua-support / Java Support) + +- 前置:目标 runtime 可被发现(Node/Go/JDK)或用户自装工具存在(intelephense/gopls);缺失时 LSP 状态显示缺少工具而不是崩溃。 +- 安装 → 审批(重点核对 systemCommand 程序声明的 `commands/arguments/validationArguments`)→ 启用 → 打开匹配扩展名的文件 → 语言服务器启动、补全/诊断可用 → 停用后进程退出、入口撤销 → 更新/回滚/卸载全链。 +- lua 的 packaged 二进制:额外核对拒绝包内 reparse point/路径逃逸、`${stateDir}` 状态目录不写包内。 +- Windows 特有:`LITHE_NODE_PATH`/`LITHE_GO_PATH` 导出与 PATH 前置在从开始菜单启动(非 shell)时生效。 + +### 3.2 构建插件(gradle-build / maven-build / cargo-build) + +- 前置:宿主能力声明已含 `buildSystems`/`buildProviders`(M4/M5 之后),市场侧展示可安装而不是不支持。 +- 检测(marker/nesting/priority)→ 工具窗口按声明注册 → 模型/依赖加载(command 或 provider)→ 选项修改与 `resolveOptions` → 任务/运行/测试执行与取消 → sync 文件变更触发过期提示。 +- maven/cargo 的 provider 模型:核对 litheBuild 协商失败可诊断、崩溃重启、`refresh` 语义;两个项目窗口互不串会话。 +- Windows 特有:Maven 原有专用流程不得退化;Worker/声明式的拥有者优先规则按计划 M0 审计结论执行。 + +### 3.3 Provider 函数与设置页(随 maven-build/cargo-build) + +- 网页 `functions.list/invoke/cancel` 走本插件 Provider;`settingActions` 按钮与网页同链路;`restart/immediate/reload/nextRun` 四种 effect 在真实进程上观察。 +- 关闭页面/项目、停用、更新时无残留调用与旧进度。 + +### 3.4 原生 UI 示例(M7 后) + +- 树/列表/表单/工具栏/右键动作渲染;中文输入、键盘导航、浅深主题、窄面板回退、状态恢复、并发动作;停用后入口与窗口撤销。 +- 更新 `declarative-native-plugin-ui` 矩阵前,上述全部为验收项而非演示项。 + +### 3.5 项目生成器(M8 后) + +- 欢迎页/菜单进入通用新建项目窗口 → 插件生成器字段与步骤 → 目标旁临时 staging → 成功后移动,失败/取消删除 → Git 初始化与 JDK 设置 → 打开后进入语言导入与构建识别。 +- 目标目录已存在或被并发创建时拒绝覆盖;失败无半成品;成功不写安装目录。 + +### 3.6 VCS Provider(M9 后) + +- 根识别 → 状态/提交/更新/回滚入口按协商能力显示 → 关闭项目窗口停止 Provider 会话 → 嵌套根归属最深 → 缺少 svn 客户端、凭据/网络失败按稳定码展示。 +- Git 主流程不回归是并行退出条件。 + +## 4. 阶段出口核对单 + +第一阶段出口(M1–M6 完成后执行): + +- [ ] 至少一个真实市场包完成 §1 全链(1–11)并归档证据。 +- [ ] 语言(php/go/lua 之一)、构建(gradle/maven 之一)、Provider+函数与设置页各有真实包行完成。 +- [ ] Windows bundled PhpSupportContract 用真实 Intelephense(或明确的分发决定)完成完整实机链。 +- [ ] windows-x64 每行有记录;windows-arm64 的 pending 逐行显式列出。 +- [ ] 每行发行资源哈希不变 + 进程树零残留核对通过。 +- [ ] `plugin-functions`、`plugin-registered-settings-pages`、网页桥相关矩阵按实际范围更新(归属相应里程碑 PR,不在本文)。 + +第二阶段出口(M7–M9 完成后执行): + +- [ ] 原生 UI 示例、项目生成器包、VCS Provider 包各自完成适用子流程。 +- [ ] 生成器"生成 → 打开 → 语言服务 → 构建/运行"链有真实包证据。 +- [ ] arm64 pending 重新评估;仍缺实机则继续显式 pending。 +- [ ] 哈希/进程清理要求同第一阶段。 + +## 5. 数据来源与更新规则 + +- 仓库元数据/release/索引:gh API(`repos/Lithe-IDEA/*`、`repos/Lithe-IDEA/Lithe-store/contents/lithe-plugin-index.json`);许可取仓库 license 字段与包内 LICENSE 文件。 +- 契约语义与宿主边界:`shared/contracts/plugins/v1/README.md`、[Windows 声明式插件宿主 Note](../../.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md)、[Go/PHP 社区化 Note](../../.agents/notes/implemented/architecture/2026-10-20-go-php-community-plugins.md)、[Java 语言服务器插件 Note](../../.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md)。 +- 更新规则:本文只固定"验收输入的身份与步骤"。每次执行验收时重新 fetch 来源,把当时的 tag、版本、SHA-256、架构、宿主 SHA 写入当次验收记录;本文矩阵行与当次记录冲突时,以当次记录为准并回写本文。不得在本文新增下载物、私钥或无版本生成资源;不把模拟测试或单测当作实机交付。 diff --git a/docs/development/windows-plugin-build-implementation.md b/docs/development/windows-plugin-build-implementation.md new file mode 100644 index 0000000000..ea2832c032 --- /dev/null +++ b/docs/development/windows-plugin-build-implementation.md @@ -0,0 +1,143 @@ +# Windows 声明式构建执行模型(M4)实施清单 + +交付方式:本文下述 PR 切分现仅表示逻辑实现与验证批次。按用户决定,所有批次统一累计到一个 PR,不单独创建阶段或文档 PR。 + +状态:设计待实施。本文是 M4 设计的可执行拆解,不是已完成能力的记录。设计决策与理由以 +[proposed Note:Windows 声明式构建执行模型与安全调度](../../.agents/notes/proposed/architecture/2026-10-25-windows-declarative-build-execution.md) +为准;正式阶段边界以 [windows-plugin-parity-plan.md](windows-plugin-parity-plan.md) 的 M4 为准。 + +基线:`1e78b59b6385fca6a881639b42048bf3bc587f4a`(#1424 合入后的 preview)。M1 为 PR #1476 +(head `3408f781`,主审中),其信任链/安装事务修复合入前,本清单的宿主接线批次不得开始改 +`plugin_host` 产品代码。 + +## 0. 全局约定 + +- Core 14 个 `build.*` 命令(`rust/lithe-core/src/build/commands.rs:91-246`)只在宿主调用, + 不复制语义;宿主入口统一走 `plugin_host/scan.rs:190` 的 `core_execute`。 +- 前端命令按普通 Tauri 命令注册(`declarative_plugins_build_*` 前缀),不进 + `platform/tauri-core.ts` 的点分调度命名空间(见 `tauri-core.ts:18` 的规则与路由测试)。 +- `buildProviders` 全程关闭(M5);`buildSystems:[1,2]` 与 `executableResolution:[1]` 只在 + PR-C 行为完整验收后加入 `host_capabilities`(`plugin_host/mod.rs:37`)。 +- 矩阵:本清单对应的实现 PR 各自更新 `shared/platform-feature-matrix/features/` 相应条目; + `declarative-plugin-contract.json` 归 M1 所有,改动前先与 M1 协调。 + +## 1. M1 阻塞接口(只消费,不定义) + +| # | M1 提供 | M4 消费点 | +| --- | --- | --- | +| 1 | 包信任状态查询:已安装、已接受、已启用、当前版本字节与安装记录一致(含 M1 验签/摘要结果) | 每次构建执行的授权链第 1 步 | +| 2 | 安装审查已批准程序/权限记录(command 模型命令、任务/测试命令、打包运行时) | `projectFile`/`command` 执行的同意来源 | +| 3 | `lifecycle.rs` 隔离 API(`blocked/acquire/NativeLease`) | 构建进程注册为原生使用者,等待停用/替换/卸载 | +| 4 | `host_capabilities` 与 `declarative-plugin-contract` 矩阵的唯一修改权 | PR-C 串行在其后声明能力 | + +依赖规则:需要新增 M1 接口时,先在 proposed Note 验收标准补记录,由 M1 落地;M4 PR 不改 +`store.rs/install.rs` 的 schema。 + +## 2. PR-A:只读检测与展示(static 线) + +新增: + +- `windows/tauri/src-tauri/src/plugin_host/build.rs`:构建会话登记表(键 + `(host window label, workspace ID, pluginID, buildSystemID, instance root)`),detect、 + static 模型、`resolveOptions`(含 `dropped` 改存)、`syncFingerprint` 监听与过期标记、 + 选项持久化(全局 `app_local_data_dir()/plugin-build-options/`,按工作区 + buildSystemID; + 空值语义照抄 macOS:显式清空是有效值)。 +- `windows/tauri/src/features/build-systems/`:统一 TS service(每窗口展示状态)+ 工具窗口 + 注册(`.build-systems:`,插件启用即注册,无实例显示空状态)+ 实例 + 根选择器(候选与生效值取所选实例)。 + +修改:`main.rs`(命令注册)、`declarative-plugins-section.tsx`(无界面改动则不动)。 + +验收(真实接线): + +1. `build-static-file` fixture(Cargo)经真实 Core 识别,工具窗口展示工程/目标/任务树。 +2. 选项修改经 `build.resolveOptions` 校验后持久化;`dropped` 选项改存生效值;重开工作区保留。 +3. 构建文件变化 → `syncFingerprint` 变化 → 窗口显示"需要重新加载",不自动重跑。 +4. 停用插件 → 窗口与命令撤销(复用 `declarative-plugin-ui.ts` 撤销模式)。 + +有界测试(`plugin_host/tests.rs` 或新增 `build_tests.rs` + bun):并发 detect/load 用受控假 +执行器;登记表代次/槽位拒绝;无真实 sleep、无网络。 + +## 3. PR-B:进程执行与 Maven 对照 + +新增: + +- `windows/tauri/src-tauri/src/plugin_host/build_resolver.rs`:五种候选来源 + (`projectFile/environment/command/runtime/setting`,`rust/lithe-core/src/plugins/executables.rs:82`) + 的 Windows 解析;`build.invocationPlan` + `build.checkToolVersion`(复用 + `launch.rs:430` 的 `lithe_git_host::run` Job Object 执行器,5s 探测期限、每流上限); + setting 来源为空跳过、非空无效终止失败;Runtime 候选自带 PATH、失败候选不传递。 +- 命令模型加载:`build.modelPlan/decodeModel`、`build.dependencyPlan/decodeDependencies`; + stdout 单独捕获交 Core、stderr 进 Build Output;有界收集 + 取消 + 输出上限。 +- 任务/测试:`build.taskPlan/testPlan` 后经现有运行管线执行(`run.rs:675` + `run_resolve_launch`、`run.rs:68` `RunProcessManager`),登记构建归属; + `build.decodeDiagnostics/decodeTestResults` 结果进 Build Output,路径导航复用现有打开入口。 +- `build.languageImport` 接 Java 启动就绪链(`windows/tauri/src/platform/java-launch-readiness.ts`)。 + +修改:`plugin_host/build.rs`(执行槽位与 NativeLease)、`run.rs`(归属登记)、`web.rs` 不动。 + +验收(真实接线): + +1. `build-command-stdout`(Go)、`build-command-output-file`(Gradle)、 + `build-host-integration`(Maven)fixture 宿主行为覆盖;argv 与 `build-plans.json` 标准答案一致。 +2. 现有 Maven 专用流程(`maven.rs`、`features/maven/`)回归不退化;声明式 Maven 接管后从 + 空状态开始,不沿用专用缓存。 +3. 取消模型加载、等待同意时切换项目、配置路径带空格、setting 值失效(终止失败)全部覆盖。 +4. 停止后确认子进程树结束(Job Object),不是仅从列表移除。 + +有界测试:进程用固定夹具可执行文件与可控完成顺序;迟到输出/旧代次写回拒绝;取消先于启动。 + +## 4. PR-C:executableResolution、统一桥与能力开放 + +范围: + +- `executableResolution:1` 完整语义(排序元数据 `priority/insertBefore/insertAfter/platforms`, + `executables.rs:115` 的 `uses_resolution`)与 `buildSystems:2` 的 `invocation.executableSetting` + (`build/manifest.rs:1368` 门控:需要 v2 + effect reload/nextRun 的可执行全局设置)。 +- 工具路径用户选择复用声明式设置存储(`plugin_host/settings.rs:15` 全局 / `:26` 项目覆盖), + 与 Core `resolveOptions`/设置推送同源。 +- `web.rs` 8 个 `build.*` 方法(现拒绝于 `web.rs:787`)改为经登记表转发:页面身份 + (`with_page_owner`,`web.rs:80`)、同插件归属、与原生同一条授权链;`build.cancel` 只取消 + 本页面/本窗口操作;页面关闭/导航/工作区切换取消在途并丢弃迟到回复。 +- `host_capabilities` 增加 `"buildSystems": [1, 2], "executableResolution": [1]`(在 M1 合入 + 之后、本批验收通过后);能力门控测试随声明更新(参照 settings-v2 fixture 模式)。 +- 矩阵更新:`build-system-plugins.json`(Windows → implemented + pending)、 + `plugin-web-tool-windows.json`(build 方法接线说明)。 + +验收(真实接线): + +1. `build-tool-path` fixture 端到端:setting 显式无效 → 终止失败且不回退 PATH; + 空值 → 跳过候选继续数组顺序。 +2. 至少一个真实声明式构建插件完成:保存 → 同步 → 模型 → 运行/测试 → 停止 → 重新打开。 +3. 网页与原生同链:同一实例状态、同一忙碌冲突、同一取消语义;跨窗口互不可见。 +4. 停用/更新/回滚/卸载期间:`blocked` 拒绝新操作、已运行进程等待退出、页面关闭、 + 无任务用旧包字节重启。 + +有界测试:桥的跨 owner/过期代次/取消竞态;双窗口状态隔离;能力门控翻转前后对比。 + +## 5. 场景覆盖矩阵(每批必查) + +| 场景 | PR-A | PR-B | PR-C | +| --- | --- | --- | --- | +| 执行失败(缺可执行文件、非零退出、输出超限、解码失败) | – | ✔ | ✔ | +| 取消与迟到结果(用户取消/窗口关闭/切工作区/停用插件) | detect 级 | ✔ | ✔ | +| 跨窗口(互不取消、各自忙碌冲突、双窗口隔离) | 登记 | ✔ | ✔ | +| 过期结果(代次变化后迟到完成只标过期,不覆盖) | ✔ | ✔ | ✔ | +| 权限变化(M1 停用/更新/回滚/卸载、包字节替换) | 撤销窗口 | NativeLease | ✔ 全链 | +| 升级/卸载(进程树确认结束、登记表清理、无旧字节重启) | – | ✔ | ✔ | + +## 6. 验证命令(实现 PR 按范围选择) + +```powershell +node scripts/verify-agent-notes.mjs +cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml plugin_host +# 工作目录 windows/tauri +bun run typecheck +./scripts/verify-windows-boundaries.ps1 +./.agents/skills/write-stable-tests/scripts/verify-test-stability.ps1 +node scripts/generate-platform-feature-matrix.mjs --check +node scripts/verify-platform-feature-matrix-change.mjs +``` + +本清单与 proposed Note 属设计文档,未修改产品代码、共享契约、矩阵或测试;所有源码定位在 +基线 `1e78b59b` 核对,M1 相关结论以 PR #1476 合并后的最终形态为准。 diff --git a/docs/development/windows-plugin-fixture-coverage.md b/docs/development/windows-plugin-fixture-coverage.md new file mode 100644 index 0000000000..e4943d710c --- /dev/null +++ b/docs/development/windows-plugin-fixture-coverage.md @@ -0,0 +1,125 @@ +# Windows 插件 fixture 覆盖审计(M0) + +状态:M0 交付。证据在基线 commit `1e78b59b6385fca6a881639b42048bf3bc587f4a`(#1424 合入后的 preview 起点)上逐一核对,并已同步 preview `83017afa`(插件后端能力 `8471b998`/#1470 合入后的契约状态,新增 `backend.plugin.json`);行号为对应时点。 + +本文回答路线图 +[Windows 插件功能对齐计划](windows-plugin-parity-plan.md) M0 的要求:`shared/contracts/plugins/v1/fixtures` 的每个文件,是否有 Core 契约验证、是否有 Windows 产品宿主的**真实接线**测试,缺什么、归哪个阶段。机器可读清单是 +`scripts/plugin-fixture-coverage.json`,由 +`scripts/check-plugin-fixture-coverage.mjs` 持续校验;本文是人类可读的审计快照,两者由检测器的强制单测绑定。 + +**总原则:Core schema 校验通过不等于 Windows 产品完成。** `invalid-manifests.json` 的 154 个用例循环只证明"宿主校验路径对同一批非法输入报同一个稳定错误码",不证明安装、启动、停用、卸载、会话回收等宿主行为已经接线。下文按此口径标注。 + +## 1. 结论摘要 + +- fixtures 目录共 36 个文件(其中 signature-vectors.json 是本统一 PR 新登记的双端宿主签名向量,其余为原 35 项审计快照):20 个合法清单(含原生 UI 3 个、项目生成 1 个、VCS 1 个,分类时单列;backend 为冻结路线图后新增)、1 个非法修改案例集、5 个金样/计划文件(command-plans、build-plans、runtime-versions、function-protocol、project-plans)、5 个市场用例、4 个项目生成模板。另有 `README.md` 与两个 schema 在契约根目录,不在 fixtures 内。 +- Core 侧:35 个文件全部有至少一个 Core 测试引用(模板目录经 `plan.rs` 的 `collect_files` 生产代码递归读取,被 golden 测试间接触发)。 +- Windows 侧:**3 个 wired**(有产品路径测试)、**11 个 validated-only**(仅清单验收/能力门控)、**10 个 indirect-core**(Windows 经 Core 命令消费、无直接读取)、**12 个 unwired**(Windows 零引用)。 +- 最严重的真实缺口:`build-tool-path`、`build-provider-language-server`、`native-panel`、`native-form`、`native-controls`、`project-generator`、`vcs-provider`、`backend` 在 Windows 侧零引用;全部 5 个市场用例和 5 个跨平台金样在 Windows 没有直接消费测试。 +- 顺带发现(只记录,不修改,归属见 §5):`tests.rs:149` 的门控注释已过时;`lithe-plugin-docs` 的 `contract.lock.json` 落后于主仓库契约;macOS `BuildSystemToolWindowTests` 用本地 fixture 副本,共享 fixture 的改动不会反映到它。 + +## 2. 检测工具与运行方式 + +```bash +node scripts/check-plugin-fixture-coverage.mjs # 清单 vs 目录真实枚举 +node --test scripts/check-plugin-fixture-coverage.test.mjs +``` + +检测器只做本地只读比较,失败即退出码 1,并逐行打印问题。它强制: + +| 失败类型 | 含义 | +| --- | --- | +| `unknown-fixture` | 目录里出现清单未分类的新文件(新增 fixture 忘了登记) | +| `missing-fixture` | 清单条目指向的文件已不存在(失效路径) | +| `duplicate-entry` | 同一文件被分类两次 | +| `unsorted-entry` | 清单未按路径排序(保持 diff 可审) | +| `invalid-entry` | 枚举值非法、wired 无 Windows 证据、known gap 缺阶段/说明、路径逃逸等 | +| `evidence-path` | 证据引用的仓库文件不存在(测试文件被移动/删除后证据过期) | +| `io` | 清单/目录不可读 | + +单测覆盖正反例(临时目录构造,全部同步、有界、`t.after` 清理),最后一条强制真实仓库当前状态通过,并断言 known gap 数量大于零——**不允许把状态全部改成 wired 来伪造覆盖**。关闭一个 gap 的唯一方式是给出真实的 Windows 测试引用。 + +清单里每个条目的证据字符串格式是 `<仓库相对路径>[:<行号>] <测试名>`。检测器只校验文件存在;行号是基线时点的文档,允许随后续重构漂移,但文件消失必须处理。 + +## 3. 覆盖状态语义 + +| 字段 | 值 | 含义 | +| --- | --- | --- | +| `outcome` | success / rejection / partial-support | 契约角色:成功路径 / 拒绝路径 / 合法但当前双端宿主均未完整开放的能力(build provider 两形态) | +| `windowsStatus` | wired | Windows 有产品路径测试直接使用该 fixture(扫描、安装、启动、catalog、Web 窗口、前端注册等,不止清单校验) | +| | validated-only | 仅进入 Windows 清单验收(带/不带能力门控),无产品路径测试 | +| | indirect-core | Windows 通过 Core 命令消费其内容,宿主测试不直接读取该文件 | +| | unwired | Windows 零直接引用(或仅作为 invalid-manifests 基底间接读取,单独说明) | + +非 wired 的每条都带 `gapStage` 与 `gapDetail`。`gapStage` 表示**计划中该能力接入的阶段**,不是承诺日期;取值为路线图里程碑 M0–M10,或冻结路线图之后落地的新能力用的 `followup`(按计划 §6 单列后续阶段,暂无编号)。提前实现时以真实测试关闭并更新清单。 + +## 4. 分类明细 + +### 4.1 合法清单(15 个 valid-manifest;原生 UI、项目生成、VCS 三类单列见 §4.4) + +| Fixture | 结果 | Core 入口(节选) | Windows 入口 | Windows 状态 / 缺口 | +| --- | --- | --- | --- | --- | +| backend.plugin.json | success | `declarative_plugins.rs:32/615` 后端计划与宿主服务(8471b998) | 无 | **unwired** → followup:冻结路线图(M0–M10)之后的新能力,macOS 宿主已声明 `backend:[1]`,Windows 接入按计划 §6 单列后续阶段 | + +| Fixture | 结果 | Core 入口(节选) | Windows 入口 | Windows 状态 / 缺口 | +| --- | --- | --- | --- | --- | +| language-packaged-server | success | `declarative_plugins.rs:65` 全量验证;`:81` 归一化;`:251` API1 参数字面量;`marketplace_plugins.rs:219` 审查金样 | `tests.rs:112/162/196/258` 验收+扫描+存储+catalog;`install_tests.rs:43/118/185` 快照/staging/回滚;`launch.rs:933` 启动计划解析 | **wired** | +| hello-lithe | success | `declarative_plugins.rs:65/532/610` UI 贡献与图标归一化 | `tests.rs:112`;`web.rs:1657` 文本窗口注册;前端 `declarative-plugin-ui.test.ts:33` 三处命令位与撤销 | **wired** | +| language-system-command | success | `declarative_plugins.rs:65/106/330` 隐藏文件名、probe 不得用设置 | `tests.rs:112` 验收;`tests.rs:258` bundled catalog | validated-only → M10:systemCommand 启动(PATH 解析、`LITHE_*_PATH` 导出)无 fixture 级测试;macOS 侧也零引用 | +| language-host-integration | success | `declarative_plugins.rs:65/152/276` 集成能力归一化与审查 | `tests.rs:112` 仅验收 | validated-only → M10:jdt 集成启动计划与必需包资源无宿主测试(`launch.rs` 测试用 packaged-server),真实 JDT 包随 M10 | +| node-runtime | success | `declarative_plugins.rs:65/276/351` runtime 启动审查与设置归一化 | `tests.rs:112` 仅验收 | validated-only → M10:packagedRuntime/minimumVersion 与设置解析无 fixture 级测试(PHP 契约包端到端覆盖了 runtime node 计划展开,但不是本清单) | +| web-panel | success | `declarative_plugins.rs:65/563` 网页窗口许可与包页面 | `tests.rs:112` 仅验收(宿主已声明 `ui:[1,2]`) | validated-only → M6:本 fixture 声明的包页面没有被任何宿主测试打开;`build.*`/`functions.*` 桥按 v0 边界拒绝 | +| settings-v2 | success | `declarative_plugins.rs:901/911/958` 即时设置、分层解析、危险使用拒绝 | `tests.rs:112` 验收 + `tests.rs:128` 门控断言 | validated-only → M4:注册设置页、settingActions 与分层覆盖缺 fixture 级宿主行为测试(现有覆盖走设置编辑器与 `settings_tests`,不经本清单)。门控原因是该 fixture 同时 `requires buildSystems/buildProviders`;`tests.rs:149` 注释表述过时,见 §5 | +| build-static-file | success | `build_systems.rs:128/143/218/361/1236` 检测、golden、运行项、symlink 拒绝 | `tests.rs:112` + `:128` 门控 | validated-only → M4 | +| build-command-stdout | success | `build_systems.rs:128/143/1071` | `tests.rs:112` + `:128` | validated-only → M4 | +| build-command-output-file | success | `build_systems.rs:128/143` | `tests.rs:112` + `:128` | validated-only → M4 | +| build-host-integration | success | `build_systems.rs:128/143/482`;`build_runtime_versions.rs:15`;`project_generators.rs:92` | `tests.rs:112` + `:128` | validated-only → M4 | +| build-provider | partial-support | `declarative_plugins.rs:445`;`build_systems.rs:1115`;`marketplace_plugins.rs:219/332` | `tests.rs:112` + `:128` 门控 | validated-only → M5:自有进程 Provider 启动、litheBuild 协商、会话回收无宿主测试 | +| build-provider-language-server | partial-support | `declarative_plugins.rs:366/445`;`build_systems.rs:1460`;`marketplace_plugins.rs:219` | 仅经 `tests.rs:80` 作为 4 个非法用例基底间接读取 | **unwired** → M5:不在任何 Windows 显式清单,连"被门控拒绝"都无专属断言 | +| build-tool-path | success | `build_systems.rs:1599/1666/1719/1755` executableResolution 全部语义 | 无 | **unwired** → M4:Windows 零引用 | + +### 4.2 非法修改案例、跨平台金样与函数协议(6 个) + +| Fixture | 结果 | Core 入口 | Windows 入口 | Windows 状态 / 缺口 | +| --- | --- | --- | --- | --- | +| invalid-manifests.json(163 用例,13 个基底,含 backend) | rejection | `declarative_plugins.rs:133` | `tests.rs:80` 全量循环,宿主校验路径断言同码 | **wired**(校验路径一致性;宿主行为仍以各清单的 wired/validated-only 行为准) | +| command-plans.json | success | `declarative_plugins.rs:212` golden 展开 | 无;`tests.rs:433` 的 PHP 契约包端到端实测了 runPlan/testPlan 展开但不用本 golden | indirect-core → M5 | +| build-plans.json | success | `build_systems.rs:143` | 无 | indirect-core → M4 | +| runtime-versions.json | success | `declarative_plugins.rs:312` | 无(`launch.rs` 用 node/java/go 真实输出自测解析,不读本 golden) | indirect-core → M10:建议接入宿主探测路径时用本 fixture 对齐 | +| function-protocol.json | success | `plugin_function.rs:558` 起 8 组协议测试(v2 新增 hostRequests) | 无;网页桥 v0 对 `functions.*` 返回 `plugin.web.unknownMethod` | indirect-core → M6 | +| project-plans.json | success | `project_generators.rs:65` | 无 | indirect-core → M8 | + +### 4.3 市场用例(5 个 marketplace/) + +全部仅 Core:`marketplace_plugins.rs:100`(索引可用性)、`:131`(归一化排序)、`:147`(非法条目拒绝)、`:203`(来源 URL 解析)、`:219`(安装审查金样)、`:272`(ui 可用性需平台宿主)、`:332`(Provider 版本需宿主支持)。Windows 全部 indirect-core → M2:声明式市场宿主未接,索引消费、下载校验与来源记录均无 Windows 测试。其中 `install-reviews.json` 补充:Windows 磁盘安装链已调用 `installReview` 命令,但测试用 `language-packaged-server` 清单而非本审查金样矩阵。 + +### 4.4 原生 UI、项目生成与 VCS(9 个) + +| Fixture | Core 入口 | Windows 入口 | Windows 状态 / 缺口 | +| --- | --- | --- | --- | +| native-panel.plugin.json | `declarative_plugins.rs:666/785` 版本归属、动作与预算 | 无 | **unwired** → M7 | +| native-form.plugin.json | `declarative_plugins.rs:721/785` 表单值/动作/网格 | 无 | **unwired** → M7 | +| native-controls.plugin.json | `declarative_plugins.rs:827` 公共控件类型/范围/按钮 | 无 | **unwired** → M7 | +| project-generator.plugin.json | `project_generators.rs:65/92/259/327/339` 表单/计划/归档/审查 | 无 | **unwired** → M8 | +| project-generator-templates/.gitattributes | 经 `plan.rs collect_files` 递归读取(字节稳定标记) | 无 | indirect-core → M8 | +| project-generator-templates/app/README.md | 同上(files 步骤模板) | 无 | indirect-core → M8 | +| project-generator-templates/app/dot-gitignore | 同上(dot 命名规避 git 忽略) | 无 | indirect-core → M8 | +| project-generator-templates/main/Main.java | 同上(条件占位符模板) | 无 | indirect-core → M8 | +| vcs-provider.plugin.json | `vcs.rs:65/94/147/196` 审查、权限、启动计划、根识别 | 无 | **unwired** → M9 | + +## 5. 横切发现(记录,不在本 PR 修改) + +1. **过时门控注释**:`windows/tauri/src-tauri/src/plugin_host/tests.rs:149` 注释称 settings-v2 被门控是因为"settings 2 与 languageServers 2 未声明";实际 `mod.rs::host_capabilities` 已声明 `settings:[1,2]`、`languageServers:[1,2]`,真正的门控原因是该 fixture 同时 `requires buildSystems:1, buildProviders:1`。断言本身仍通过且有效,注释需在下次触碰该测试的 PR 中修正。 +2. **契约同步缺口**:`Lithe-IDEA/lithe-plugin-docs` 的 `contract.lock.json`(gh 核实于 2026-10-24)固定在旧修订 `18345cc9…`,文件清单缺少后加入的 `native-panel/native-form/native-controls`、`project-generator`、`project-generator-templates/` 全目录与 `project-plans.json`。契约 README 规定契约改动必须同步该仓库;这属于 [插件契约发布](../../.agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md) 所有者的待办。 +3. **macOS 本地副本**:`macos/Tests/LitheTests/Fixtures/BuildSystems/` 下的 `build-command-output-file`、`build-host-integration` 等是共享 fixture 的本地副本,`BuildSystemToolWindowTests` 经 `Bundle.module` 加载副本。共享 fixture 语义变更不会自动反映到这批测试,属已知重复面,不在本轮收敛。 +4. **非法清单循环的边界**:Windows `invalid-manifests` 循环(154 用例)与 macOS 同名测试是契约一致性回归;它们不能替代宿主行为测试,也不能作为"Windows 已支持某能力"的证据——被门控清单(如 build 组)的全量拒绝恰恰说明能力未开放。 +5. **fixtur 目录枚举现状**:Core 测试全部用 `include_str!`/显式 const 清单,Windows 测试用 `read_fixture(name)` 显式点名;唯一对 fixtures 树的运行时递归读取是 `plan.rs` 的模板收集。这就是"新增 fixture 会静默漏掉"的结构性原因,也是本检测器存在的理由。`scripts/verify-shared-contracts.sh` 的语义校验(schemaVersion、ID 前缀、拒绝码文档化等)与本检测器互补,不互相替代。 +6. **过时的宿主能力描述**:`shared/contracts/plugins/v1/README.md` 能力一节(preview 上仍在 §456 附近)称 macOS 不声明 buildProviders/vcsProviders;基线与 preview 的 `PluginTypes.swift` `PluginManifest.hostCapabilities`(preview 为 `:492-504`)实际声明 `buildProviders:[1]、buildSystems:[1,2]、executableResolution:[1]、executionProviders:[1]、projectGenerators:[1]、ui:[1,2,3]、vcsProviders:[1]`(preview 另加 `backend:[1]`),Core `capabilities.rs` 的 `SUPPORTED_CAPABILITIES` 支持同一组(含 `buildSystems:2`)。功能矩阵佐证:`build-provider-refresh-and-tool` 与 `cargo-build-plugin` 记 macOS implemented/验证 pending。README 落后于代码,能力事实以 Core 与功能矩阵为准,不能以旧 README 推断安装可用性。 + +## 6. 维护规则 + +- 新增 fixture:同 PR 在 `scripts/plugin-fixture-coverage.json` 登记分类与双端证据;Windows 尚无测试时如实标 known gap(状态、阶段、说明),不得标 wired。 +- 删除/重命名 fixture:同 PR 清理清单,否则 `missing-fixture` 失败。 +- 移动/删除被引用的测试文件:更新证据条目,否则 `evidence-path` 失败。 +- 关闭 gap:以真实 Windows 测试为证据更新 `windowsStatus`/`windowsEvidence` 并清空 gap 字段;检测器会拒绝"没有证据的 wired"。 +- 统一交付:原 #1474、#1476、#1477 的改动收进同一分支与 PR;签名 fixture 和其分类同步提交。signature-vectors 类别允许 coreEvidence 为空,因为消费方是 Swift/Rust 宿主签名测试;当前按 validated-only/M1 记录,完整安装审批与失败恢复仍需生产路径验证。后续代码及覆盖登记只累积到统一 PR。 +- 本文是审计快照;实时约束以清单 + 检测器为准。行号漂移不需要修本文,但证据文件消失必须处理。 diff --git a/frontend/editor/package.json b/frontend/editor/package.json index 5fd501b891..022a7aba95 100644 --- a/frontend/editor/package.json +++ b/frontend/editor/package.json @@ -11,5 +11,5 @@ "vscode-oniguruma": "1.7.0", "vscode-textmate": "9.3.2" }, - "packageManager": "bun@1.3.12" + "packageManager": "bun@1.3.14" } diff --git a/macos/Tests/LitheTests/PluginSignatureVectorTests.swift b/macos/Tests/LitheTests/PluginSignatureVectorTests.swift new file mode 100644 index 0000000000..ff47e5dc64 --- /dev/null +++ b/macos/Tests/LitheTests/PluginSignatureVectorTests.swift @@ -0,0 +1,180 @@ +import CryptoKit +import Foundation +import LithePluginPackageSigning +import Testing + +/// Cross-platform acceptance for the package signature: the same fixed +/// vectors answer identically on Swift/CryptoKit and the Windows Rust host. +/// The vectors live in +/// `shared/contracts/plugins/v1/fixtures/signature-vectors.json` and anchor +/// the Ed25519 primitive to the public RFC 8032 test key, so neither +/// platform's own round-trip can drift. +struct PluginSignatureVectorTests { + private struct Fixture: Codable { + struct Ed25519: Codable { + let seedHex: String + let publicKeyHex: String + let messageEmptySignatureHex: String + } + struct CanonicalPayload: Codable { + let keyID: String + let pluginID: String + let pluginVersion: String + let files: [String: String] + let payloadBase64: String + let signatureBase64: String + } + struct ContentDigest: Codable { + let files: [String: String] + let digest: String + } + + let ed25519: Ed25519 + let packageFiles: [String: String] + let canonicalPayload: CanonicalPayload + let contentDigest: ContentDigest + } + + private static func fixture() throws -> Fixture { + let url = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/signature-vectors.json") + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + private static func privateKey(from fixture: Fixture) throws -> Curve25519.Signing.PrivateKey { + var seed = Data() + var index = fixture.ed25519.seedHex.startIndex + while index < fixture.ed25519.seedHex.endIndex { + let next = fixture.ed25519.seedHex.index(index, offsetBy: 2) + seed.append(UInt8(fixture.ed25519.seedHex[index.. URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-signature-vector-\(UUID().uuidString)", isDirectory: true) + for (relativePath, content) in fixture.packageFiles { + let target = root.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: target.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data(content.utf8).write(to: target) + } + return root + } +} + +extension Data { + fileprivate var hex: String { + map { String(format: "%02x", $0) }.joined() + } +} diff --git a/package.json b/package.json index 66a831ceae..0b3ba1612b 100644 --- a/package.json +++ b/package.json @@ -5,5 +5,5 @@ "frontend/editor", "windows/tauri" ], - "packageManager": "bun@1.3.12" + "packageManager": "bun@1.3.14" } diff --git a/rust/lithe-core/src/build/detect.rs b/rust/lithe-core/src/build/detect.rs index 5246966243..703d7b2ba4 100644 --- a/rust/lithe-core/src/build/detect.rs +++ b/rust/lithe-core/src/build/detect.rs @@ -11,9 +11,12 @@ use super::manifest::{BuildSystemDeclaration, ModelSource, Nesting}; use super::options; use super::outputs::{model_value, read_model, source_marker}; use crate::plugins::declarative::{validate_manifest_value, PluginRejection, Validation}; +use crate::protocol::CoreError; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; +use std::io::ErrorKind; +use std::path::Path; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -121,6 +124,29 @@ fn instance( instance } +// Detection historically accepts absent/non-directory roots as an empty view. +// Keep that boundary local: scans of real directories must still report I/O +// failures, and the shared run scanner retains its required-root semantics. +fn scan_directory_root( + root: &Path, + scan: impl FnOnce(&Path) -> Result, CoreError>, +) -> Validation> { + match std::fs::metadata(root) { + Ok(metadata) if !metadata.is_dir() => return Ok(Vec::new()), + Err(error) if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => { + return Ok(Vec::new()); + } + Err(error) => { + return Err(PluginRejection::new( + "plugin.build.invalidSelection", + error.to_string(), + )); + } + Ok(_) => {} + } + scan(root).map_err(|error| PluginRejection::new("plugin.build.invalidSelection", error.message)) +} + /// `build.detect`: every instance of every build system the enabled plugins /// declare. Invalid manifests are reported, not fatal; when two plugins /// declare one build-system ID, the higher priority answers for it. @@ -177,8 +203,7 @@ pub(crate) fn detect(request: DetectRequest) -> Validation { if owners.is_empty() { return Ok(json!({ "instances": [], "rejected": rejected, "shadowed": shadowed })); } - let directories = crate::execution::scan(&workspace.path) - .map_err(|error| PluginRejection::new("plugin.build.invalidSelection", error.message))?; + let directories = scan_directory_root(&workspace.path, crate::execution::scan)?; let empty = BTreeMap::new(); let mut seen = BTreeSet::new(); let mut instances = Vec::new(); @@ -197,3 +222,29 @@ pub(crate) fn detect(request: DetectRequest) -> Validation { "shadowed": shadowed, })) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn existing_directory_scan_errors_are_not_empty_detections() { + // A real readable directory passes metadata. Inject the subsequent + // failure so ACL differences and elevated test users cannot bypass it. + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + assert!(root.is_dir()); + for kind in [ + ErrorKind::PermissionDenied, + ErrorKind::NotFound, + ErrorKind::Other, + ] { + let error = scan_directory_root(root, |scanned| { + assert_eq!(scanned, root); + Err(std::io::Error::new(kind, "controlled directory scan failure").into()) + }) + .expect_err("an existing directory's scan failure must reject detection"); + assert_eq!(error.code, "plugin.build.invalidSelection"); + assert_eq!(error.message, "controlled directory scan failure"); + } + } +} diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index 45d4696b24..2905ea53c4 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -33,6 +33,28 @@ pub fn execute_json(request: &str) -> String { runtime::execute_json(request) } +/// Starts the existing LSP engine with a native resource owner attached before +/// initialization. The callback only records the ID; it must not call Core. +/// Unlike a wire request, this also preserves ownership when the first write +/// fails and the ordinary start command returns an error. +pub fn start_lsp_owned( + payload: serde_json::Value, + registered: impl FnOnce(&str), +) -> Result { + let request = serde_json::from_value(payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid language-server launch.") + .with_details(error.to_string()) + })?; + let response = lsp::interface::start_server_owned(request, registered)?; + serde_json::to_value(response).map_err(|error| { + CoreError::new( + ErrorCode::Unknown, + "Could not encode language-server launch.", + ) + .with_details(error.to_string()) + }) +} + /// Requests cooperative cancellation of an active operation. /// /// Native Rust hosts use this entry point while the Swift and C++ clients keep diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index e1b7285a23..4965357137 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -886,6 +886,14 @@ pub fn start_server(request: StartServerRequest) -> Result Result { + engine().start_server_owned(request, registered) +} + /// Performs a bounded graceful shutdown while retaining the session record. pub fn stop_server(request: SessionRequest) -> Result<(), CoreError> { engine().session(&request.session_id)?.stop() @@ -1466,6 +1474,14 @@ impl LspEngine { } fn start_server(&self, request: StartServerRequest) -> Result { + self.start_server_owned(request, |_| {}) + } + + fn start_server_owned( + &self, + request: StartServerRequest, + registered: impl FnOnce(&str), + ) -> Result { validate_start_request(&request)?; let startup_started_at = Instant::now(); let session_id = format!( @@ -1584,16 +1600,6 @@ impl LspEngine { _ => None, }; - let process = self.launcher.launch(LspProcessSpec { - executable: adaptation - .executable - .unwrap_or_else(|| PathBuf::from(&request.executable_path)), - arguments: adaptation.arguments, - working_directory: workspace_root, - environment: request.environment, - })?; - let process_start_elapsed = startup_started_at.elapsed(); - let initialize_timeout = Duration::from_millis(request.initialize_timeout_milliseconds); let service_ready_idle_timeout = Duration::from_millis(request.service_ready_idle_timeout_milliseconds); @@ -1630,6 +1636,16 @@ impl LspEngine { }, request.plugin_id.is_some(), )?; + let process = self.launcher.launch(LspProcessSpec { + executable: adaptation + .executable + .unwrap_or_else(|| PathBuf::from(&request.executable_path)), + arguments: adaptation.arguments, + working_directory: workspace_root, + environment: request.environment, + })?; + let process_start_elapsed = startup_started_at.elapsed(); + let request_id = (initialize.state.next_request_id - 1).to_string(); let now = Instant::now(); let process_id = process.handle.process_id(); @@ -1764,6 +1780,9 @@ impl LspEngine { self.lock_sessions()? .insert(session_id.clone(), session.clone()); + // Native owners learn the identity before initialize writes can fail. + // They keep their package barrier until destroy verifies disposal. + registered(&session_id); session.spawn_readers(process.output, process.errors); session.spawn_monitor(); session.spawn_outbound_maintenance(); @@ -1806,6 +1825,7 @@ impl LspEngine { "A running language-server session cannot be destroyed.", )); } + session.process.dispose(Duration::from_secs(2))?; self.lock_sessions()?.remove(session_id); Ok(()) } @@ -6514,6 +6534,46 @@ mod tests { } } + #[test] + fn native_owner_keeps_failed_initialize_identity_until_verified_disposal() { + let server = ScriptedServer::new(); + server.break_input(); + server.block_cleanup(true); + let engine = LspEngine::with_launcher(server.launcher()); + let registered = Mutex::new(None); + let result = engine.start_server_owned(start_request(&server), |id| { + // Registration precedes the very first write, including its failure. + assert!(server.messages().is_empty()); + *registered.lock().unwrap() = Some(id.to_string()); + }); + assert!(result.is_err()); + let id = registered + .into_inner() + .unwrap() + .expect("native owner received the session"); + assert!(engine.session(&id).is_ok()); + assert!(engine.destroy(&id).is_err()); + assert!( + engine.session(&id).is_ok(), + "failed disposal must retain the process owner" + ); + server.block_cleanup(false); + engine + .destroy(&id) + .expect("verified cleanup allows destruction"); + assert!(engine.session(&id).is_err()); + } + + #[test] + fn invalid_initial_configuration_is_rejected_before_launch() { + let server = ScriptedServer::new(); + let engine = LspEngine::with_launcher(server.launcher()); + let mut request = start_request(&server); + request.workspace_configuration = Some(json!(["not an object"])); + assert!(engine.start_server(request).is_err()); + assert!(server.launched_spec().is_none()); + } + /// An engine with a scripted server behind it, plus the started session. struct Harness { engine: LspEngine, diff --git a/rust/lithe-core/src/lsp/interface/process.rs b/rust/lithe-core/src/lsp/interface/process.rs index b028c52fa6..01c9e8ce7c 100644 --- a/rust/lithe-core/src/lsp/interface/process.rs +++ b/rust/lithe-core/src/lsp/interface/process.rs @@ -17,6 +17,7 @@ use std::path::Path; use std::path::PathBuf; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::{Arc, Mutex}; +use std::time::Duration; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; @@ -32,9 +33,8 @@ pub struct LspProcessSpec { /// A running language-server process. /// -/// Every method is infallible-by-design except writing: a process that has -/// already exited is a normal state the engine reports through its own events, -/// not an error to propagate from the handle. +/// Exit notification and verified disposal are separate: a dead root may +/// still own descendants. Disposal must succeed before releasing resources. pub trait LspProcessHandle: Send + Sync { /// OS process identifier when the platform exposes one. Scripted test /// handles intentionally return no identifier. @@ -55,6 +55,19 @@ pub trait LspProcessHandle: Send + Sync { /// Ends the process without waiting for it to agree. fn terminate(&self); + + /// Confirms disposal before the engine forgets the owning session. + /// Scripted handles have no descendants and report their own exit. + fn dispose(&self, _timeout: Duration) -> Result<(), CoreError> { + if self.exit_status().is_some() { + Ok(()) + } else { + Err(CoreError::new( + ErrorCode::ProcessFailed, + "Language-server cleanup is incomplete.", + )) + } + } } /// A launched process and the two streams the engine reads on its own threads. @@ -258,6 +271,26 @@ impl LspProcessHandle for SystemProcess { let _ = child.kill(); } } + + fn dispose(&self, timeout: Duration) -> Result<(), CoreError> { + #[cfg(target_os = "windows")] + self.tree.terminate(timeout).map_err(|error| { + CoreError::new( + ErrorCode::ProcessFailed, + "Language-server process tree did not exit.", + ) + .with_details(error.to_string()) + })?; + #[cfg(not(target_os = "windows"))] + let _ = timeout; + if self.exit_status().is_none() { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Language-server root did not exit.", + )); + } + Ok(()) + } } fn missing_stream(stream: &str) -> CoreError { diff --git a/rust/lithe-core/src/lsp/interface/scripted.rs b/rust/lithe-core/src/lsp/interface/scripted.rs index 7b865cfa5d..d08c796262 100644 --- a/rust/lithe-core/src/lsp/interface/scripted.rs +++ b/rust/lithe-core/src/lsp/interface/scripted.rs @@ -36,6 +36,8 @@ struct ScriptedShared { input_closed: AtomicBool, /// Set when the test wants writes to fail, standing in for a broken pipe. input_broken: AtomicBool, + /// Deterministic refusal of process-tree disposal after the root exits. + cleanup_blocked: AtomicBool, /// Deterministic write barrier used by concurrency-ordering tests. input_pause: Mutex, input_pause_changed: Condvar, @@ -69,6 +71,7 @@ impl ScriptedServer { exit: Mutex::new(None), input_closed: AtomicBool::new(false), input_broken: AtomicBool::new(false), + cleanup_blocked: AtomicBool::new(false), input_pause: Mutex::new(ScriptedInputPause::default()), input_pause_changed: Condvar::new(), spec: Mutex::new(None), @@ -252,6 +255,13 @@ impl ScriptedServer { self.shared.input_broken.store(true, Ordering::Release); } + /// Simulates a descendant that survives the root, without real processes. + pub fn block_cleanup(&self, blocked: bool) { + self.shared + .cleanup_blocked + .store(blocked, Ordering::Release); + } + /// Stops the next engine write before any bytes reach the scripted server. pub fn pause_input(&self) { let mut pause = self.shared.input_pause.lock().unwrap(); @@ -392,6 +402,16 @@ impl LspProcessHandle for ScriptedProcess { *self.shared.exit.lock().unwrap() } + fn dispose(&self, _timeout: Duration) -> Result<(), CoreError> { + if self.shared.cleanup_blocked.load(Ordering::Acquire) || self.exit_status().is_none() { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Scripted process cleanup is incomplete.", + )); + } + Ok(()) + } + fn terminate(&self) { let mut exit = self.shared.exit.lock().unwrap(); if exit.is_none() { diff --git a/rust/lithe-core/src/project_generators/archive.rs b/rust/lithe-core/src/project_generators/archive.rs index 7628f1a90f..d477cc40b3 100644 --- a/rust/lithe-core/src/project_generators/archive.rs +++ b/rust/lithe-core/src/project_generators/archive.rs @@ -6,49 +6,88 @@ //! bounded regardless of what the headers claim. use crate::plugins::declarative::{PluginRejection, Validation}; +use crate::protocol::CoreError; +use std::collections::HashSet; use std::fs::{self, File, OpenOptions}; use std::io::{self, Read, Write}; use std::path::{Component, Path, PathBuf}; const MAX_ENTRIES: usize = 20_000; const MAX_EXTRACTED_BYTES: u64 = 512 * 1024 * 1024; +const COPY_CHUNK_BYTES: usize = 32 * 1024; +const MAX_DEPTH: usize = 64; +const MAX_PATH_BYTES: usize = 16 * 1024 * 1024; const ARCHIVE_INVALID: &str = "plugin.project.archiveInvalid"; /// `S_IFMT` and `S_IFLNK` of a Unix mode stored in a zip entry. const FILE_TYPE_MASK: u32 = 0o170000; const SYMLINK_TYPE: u32 = 0o120000; +/// Separates invalid archive content from the first typed operation interruption. +#[derive(Debug)] +pub(crate) enum ExtractionError { + /// The archive or destination violates the extraction contract. + Invalid(PluginRejection), + /// Cancellation or timeout detected while all extraction handles are owned. + Interrupted(CoreError), +} + +impl From for ExtractionError { + fn from(error: PluginRejection) -> Self { + Self::Invalid(error) + } +} + +impl From for ExtractionError { + fn from(error: CoreError) -> Self { + Self::Interrupted(error) + } +} + /// Extracts `archive` into `destination`, which must be an existing -/// directory. Returns the number of files written. -pub(crate) fn extract(archive: &Path, destination: &Path) -> Validation { +/// directory. Returns the number of files written after closing its descriptors. +pub(crate) fn extract(archive: &Path, destination: &Path) -> Result { + extract_with_checkpoint(archive, destination, MAX_EXTRACTED_BYTES, &mut checkpoint) +} + +fn checkpoint() -> Result<(), CoreError> { + crate::protocol::cancellation::check() +} + +fn extract_with_checkpoint( + archive: &Path, + destination: &Path, + maximum_bytes: u64, + checkpoint: &mut dyn FnMut() -> Result<(), CoreError>, +) -> Result { + checkpoint()?; let rejected = |message: String| PluginRejection::at(ARCHIVE_INVALID, "archive", message); let destination_metadata = fs::symlink_metadata(destination) .map_err(|error| rejected(format!("The destination cannot be read: {error}")))?; - if !destination_metadata.is_dir() { - return Err(rejected("The destination is not a directory.".into())); + if !real_directory(&destination_metadata) { + return Err(rejected("The destination is not a directory.".into()).into()); } let file = File::open(archive) .map_err(|error| rejected(format!("The archive cannot be read: {error}")))?; let mut zip = zip::ZipArchive::new(file) .map_err(|error| rejected(format!("The download is not a zip archive: {error}")))?; if zip.len() > MAX_ENTRIES { - return Err(rejected("The archive has too many entries.".into())); + return Err(rejected("The archive has too many entries.".into()).into()); } + validate_entries(&mut zip, checkpoint, MAX_ENTRIES, MAX_PATH_BYTES)?; let mut written: u64 = 0; let mut files = 0; for index in 0..zip.len() { + checkpoint()?; let mut entry = zip .by_index(index) .map_err(|error| rejected(format!("Entry {index} cannot be read: {error}")))?; let name = entry.name().to_string(); - let relative = entry - .enclosed_name() - .filter(|path| is_plain_relative(path)) - .ok_or_else(|| rejected(format!("{name} leaves the project directory.")))?; + let relative = relative_entry(&entry)?; let mode = entry.unix_mode(); if mode.is_some_and(|mode| mode & FILE_TYPE_MASK == SYMLINK_TYPE) { - return Err(rejected(format!("{name} is a symbolic link."))); + return Err(rejected(format!("{name} is a symbolic link.")).into()); } let target = destination.join(&relative); ensure_directories(destination, relative.parent().unwrap_or(Path::new(""))) @@ -63,13 +102,28 @@ pub(crate) fn extract(archive: &Path, destination: &Path) -> Validation { .create_new(true) .open(&target) .map_err(|error| rejected(format!("{name} cannot be created: {error}")))?; - let remaining = MAX_EXTRACTED_BYTES - written; - let copied = io::copy(&mut (&mut entry).take(remaining + 1), &mut output) - .map_err(|error| rejected(format!("{name} cannot be extracted: {error}")))?; - if copied > remaining { - return Err(rejected( - "The archive expands beyond the size limit.".into(), - )); + let remaining = maximum_bytes - written; + let mut limited = (&mut entry).take(remaining + 1); + let mut copied = 0u64; + let mut buffer = [0u8; COPY_CHUNK_BYTES]; + loop { + checkpoint()?; + let count = limited + .read(&mut buffer) + .map_err(|error| rejected(format!("{name} cannot be extracted: {error}")))?; + if count == 0 { + break; + } + checkpoint()?; + // Read one byte beyond the budget to detect a dishonest size, but + // never write that sentinel byte into the transaction. + if copied + count as u64 > remaining { + return Err(rejected("The archive expands beyond the size limit.".into()).into()); + } + output + .write_all(&buffer[..count]) + .map_err(|error| rejected(format!("{name} cannot be extracted: {error}")))?; + copied += count as u64; } output .flush() @@ -78,14 +132,138 @@ pub(crate) fn extract(archive: &Path, destination: &Path) -> Validation { files += 1; set_executable(&target, mode).map_err(|error| rejected(format!("{name}: {error}")))?; } + checkpoint()?; Ok(files) } +fn relative_entry(entry: &zip::read::ZipFile<'_>) -> Validation { + let name = entry.name(); + // Reject traversal before ZipArchive normalizes it. Backslashes are path + // separators on Windows, so they cannot be literal names on another host. + let path = name.strip_suffix('/').unwrap_or(name); + if name.contains('\\') || path.split('/').any(|part| matches!(part, "" | "." | "..")) { + return Err(PluginRejection::at( + ARCHIVE_INVALID, + "archive", + format!("{name} leaves the project directory."), + )); + } + entry + .enclosed_name() + .filter(|path| is_plain_relative(path)) + .ok_or_else(|| { + PluginRejection::at( + ARCHIVE_INVALID, + "archive", + format!("{name} leaves the project directory."), + ) + }) +} + +/// Preflights the existing ZIP parser's entries before creating anything. The +/// node budget includes implicit parent directories; the path-byte budget bounds +/// memory even when a few deeply nested names expand into many parent paths. +fn validate_entries( + zip: &mut zip::ZipArchive, + checkpoint: &mut dyn FnMut() -> Result<(), CoreError>, + maximum_entries: usize, + maximum_path_bytes: usize, +) -> Result<(), ExtractionError> { + let mut paths = HashSet::new(); + let mut path_bytes = 0usize; + for index in 0..zip.len() { + checkpoint()?; + let entry = zip.by_index(index).map_err(|error| { + PluginRejection::at( + ARCHIVE_INVALID, + "archive", + format!("Entry {index}: {error}"), + ) + })?; + let relative = relative_entry(&entry)?; + if entry + .unix_mode() + .is_some_and(|mode| mode & FILE_TYPE_MASK == SYMLINK_TYPE) + { + return Err(PluginRejection::at( + ARCHIVE_INVALID, + "archive", + format!("{} is a symbolic link.", entry.name()), + ) + .into()); + } + for path in relative + .ancestors() + .take_while(|path| !path.as_os_str().is_empty()) + { + checkpoint()?; + if paths.insert(path.to_path_buf()) { + path_bytes = path_bytes.saturating_add(path.to_string_lossy().len()); + if paths.len() > maximum_entries || path_bytes > maximum_path_bytes { + return Err(PluginRejection::at( + ARCHIVE_INVALID, + "archive", + "The archive has too many paths or path bytes.", + ) + .into()); + } + } + } + } + Ok(()) +} + fn is_plain_relative(path: &Path) -> bool { - path.components().next().is_some() - && path - .components() - .all(|component| matches!(component, Component::Normal(_))) + let components: Vec<_> = path.components().collect(); + !components.is_empty() + && components.len() <= MAX_DEPTH + && components.iter().all(|component| match component { + Component::Normal(value) => safe_component(value), + _ => false, + }) +} + +fn real_directory(metadata: &fs::Metadata) -> bool { + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return false; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return false; + } + } + true +} + +#[cfg(windows)] +fn safe_component(value: &std::ffi::OsStr) -> bool { + let Some(value) = value.to_str() else { + return false; + }; + let base = value.split('.').next().unwrap_or("").to_ascii_uppercase(); + !value.ends_with(['.', ' ']) + && !value + .chars() + .any(|ch| ch.is_control() || "<>:\"|?*".contains(ch)) + && !matches!( + base.as_str(), + "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" + ) + && !["COM", "LPT"].iter().any(|prefix| { + base.strip_prefix(prefix).is_some_and(|n| { + matches!( + n, + "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³" + ) + }) + }) +} + +#[cfg(not(windows))] +fn safe_component(_: &std::ffi::OsStr) -> bool { + true } /// Creates each directory below `root` along `relative`, refusing to walk @@ -95,7 +273,7 @@ fn ensure_directories(root: &Path, relative: &Path) -> io::Result<()> { for component in relative.components() { current.push(component); match fs::symlink_metadata(¤t) { - Ok(metadata) if metadata.is_dir() => {} + Ok(metadata) if real_directory(&metadata) => {} Ok(_) => { return Err(io::Error::new( io::ErrorKind::AlreadyExists, @@ -232,4 +410,196 @@ mod tests { fs::create_dir(&destination).unwrap(); assert!(extract(&path, &destination).is_err()); } + + #[test] + fn cancellation_between_chunks_stops_before_the_next_write_or_entry() { + let bytes = vec![b'x'; COPY_CHUNK_BYTES * 3]; + let (directory, path) = + archive(&[("large.txt", &bytes, None), ("later.txt", b"later", None)]); + let destination = directory.path().join("project"); + fs::create_dir(&destination).unwrap(); + let id = format!("archive-chunks-{}", rand::random::()); + let _scope = crate::protocol::cancellation::Scope::begin(Some(id.clone()), Some(3000)); + let result = extract_with_checkpoint(&path, &destination, MAX_EXTRACTED_BYTES, &mut || { + // Observe one completed write, then cancel before another chunk. + if fs::metadata(destination.join("large.txt")) + .is_ok_and(|metadata| metadata.len() >= COPY_CHUNK_BYTES as u64) + { + assert!(crate::protocol::cancellation::cancel(&id)); + } + checkpoint() + }); + assert!(result.is_err()); + let written = fs::metadata(destination.join("large.txt")).unwrap().len(); + assert!(written >= COPY_CHUNK_BYTES as u64); + assert!(written < (COPY_CHUNK_BYTES * 2) as u64); + assert!(!destination.join("later.txt").exists()); + } + + #[test] + fn timeout_between_chunks_preserves_its_first_error_and_closes_the_output() { + let bytes = vec![b'x'; COPY_CHUNK_BYTES * 3]; + let (directory, path) = + archive(&[("large.txt", &bytes, None), ("later.txt", b"later", None)]); + let destination = directory.path().join("project"); + fs::create_dir(&destination).unwrap(); + let result = extract_with_checkpoint(&path, &destination, MAX_EXTRACTED_BYTES, &mut || { + if fs::metadata(destination.join("large.txt")) + .is_ok_and(|metadata| metadata.len() >= COPY_CHUNK_BYTES as u64) + { + // An already reached deadline controls the event order without + // sleeping or relying on the speed of filesystem extraction. + crate::protocol::cancellation::with_cleanup_deadline( + std::time::Duration::ZERO, + checkpoint, + ) + } else { + Ok(()) + } + }); + let Err(ExtractionError::Interrupted(error)) = result else { + panic!("chunk timeout must remain an operation interruption"); + }; + assert!(matches!(error.code, crate::protocol::ErrorCode::TimedOut)); + assert_eq!( + fs::metadata(destination.join("large.txt")).unwrap().len(), + COPY_CHUNK_BYTES as u64 + ); + assert!(!destination.join("later.txt").exists()); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + // An exclusive reopen proves the timed-out writer released its + // descriptor before the host can start transaction cleanup. + let _closed = OpenOptions::new() + .read(true) + .share_mode(0) + .open(destination.join("large.txt")) + .unwrap(); + } + } + + #[test] + fn rejects_excessive_archive_path_depth_before_creating_any_parent() { + let name = format!("{}/file.txt", vec!["a"; MAX_DEPTH].join("/")); + let (directory, path) = archive(&[(&name, b"no", None)]); + let destination = directory.path().join("project"); + fs::create_dir(&destination).unwrap(); + assert!(extract(&path, &destination).is_err()); + assert_eq!(fs::read_dir(destination).unwrap().count(), 0); + } + + #[test] + fn expanded_byte_budget_counts_actual_written_bytes() { + let bytes = vec![b'x'; COPY_CHUNK_BYTES * 2]; + let (directory, path) = archive(&[("large.txt", &bytes, None)]); + let destination = directory.path().join("project"); + fs::create_dir(&destination).unwrap(); + assert!(extract_with_checkpoint( + &path, + &destination, + COPY_CHUNK_BYTES as u64 + 16, + &mut || Ok(()) + ) + .is_err()); + let written = fs::metadata(destination.join("large.txt")).unwrap().len(); + assert!(written > 0); + assert!(written <= COPY_CHUNK_BYTES as u64 + 16); + } + + #[test] + fn implicit_parents_and_path_bytes_share_the_preflight_budget() { + let (_directory, path) = archive(&[("parent/nested/file.txt", b"no", None)]); + let mut zip = zip::ZipArchive::new(File::open(&path).unwrap()).unwrap(); + assert!(validate_entries(&mut zip, &mut || Ok(()), 2, usize::MAX).is_err()); + // The leaf (22 bytes) and its implicit parents (13 + 6) total 41. + assert!(validate_entries(&mut zip, &mut || Ok(()), 3, 40).is_err()); + assert!(validate_entries(&mut zip, &mut || Ok(()), 3, 41).is_ok()); + } + + #[test] + fn archive_paths_cannot_hide_traversal_in_normalized_components() { + for name in [ + "nested/../file.txt", + "nested/./file.txt", + "nested//file.txt", + "nested\\file.txt", + "/file.txt", + ] { + let (directory, path) = archive(&[("first.txt", b"safe", None), (name, b"no", None)]); + let destination = directory.path().join("project"); + fs::create_dir(&destination).unwrap(); + assert!(extract(&path, &destination).is_err(), "{name}"); + assert_eq!(fs::read_dir(destination).unwrap().count(), 0, "{name}"); + } + } + + #[test] + fn command_reports_stable_cancellation_and_timeout_before_any_write() { + let (directory, path) = archive(&[("file.txt", b"no", None)]); + let destination = directory.path().join("project"); + fs::create_dir(&destination).unwrap(); + let payload = serde_json::json!({"archivePath": path, "destination": destination}); + let id = format!("archive-command-{}", rand::random::()); + { + let _scope = crate::protocol::cancellation::Scope::begin(Some(id.clone()), None); + assert!(crate::protocol::cancellation::cancel(&id)); + let error = crate::project_generators::project_extract_archive_command(payload.clone()) + .unwrap_err(); + assert!(matches!(error.code, crate::protocol::ErrorCode::Cancelled)); + } + crate::protocol::cancellation::with_cleanup_deadline(std::time::Duration::ZERO, || { + let error = + crate::project_generators::project_extract_archive_command(payload).unwrap_err(); + assert!(matches!(error.code, crate::protocol::ErrorCode::TimedOut)); + }); + assert_eq!(fs::read_dir(destination).unwrap().count(), 0); + } + + #[test] + fn existing_hard_link_target_is_never_truncated_or_replaced() { + let (directory, path) = archive(&[("linked.txt", b"untrusted", None)]); + let destination = directory.path().join("project"); + fs::create_dir(&destination).unwrap(); + let outside = directory.path().join("owned.txt"); + fs::write(&outside, b"user bytes").unwrap(); + fs::hard_link(&outside, destination.join("linked.txt")).unwrap(); + assert!(extract(&path, &destination).is_err()); + assert_eq!(fs::read(&outside).unwrap(), b"user bytes"); + assert_eq!( + fs::read(destination.join("linked.txt")).unwrap(), + b"user bytes" + ); + } + + #[cfg(windows)] + #[test] + fn windows_archive_paths_reject_ads_device_names_and_verbatim_normalization() { + for name in [ + "file:stream", + "CON.txt", + "nested/LPT1", + "trailing.", + "space ", + ] { + let (directory, path) = archive(&[(name, b"no", None)]); + let destination = directory.path().join("project"); + fs::create_dir(&destination).unwrap(); + assert!(extract(&path, &destination).is_err(), "{name}"); + assert_eq!(fs::read_dir(destination).unwrap().count(), 0); + } + } + + #[cfg(unix)] + #[test] + fn refuses_existing_symlink_directories_without_touching_their_target() { + let (directory, path) = archive(&[("nested/file.txt", b"no", None)]); + let destination = directory.path().join("project"); + let outside = directory.path().join("outside"); + fs::create_dir(&destination).unwrap(); + fs::create_dir(&outside).unwrap(); + std::os::unix::fs::symlink(&outside, destination.join("nested")).unwrap(); + assert!(extract(&path, &destination).is_err()); + assert_eq!(fs::read_dir(outside).unwrap().count(), 0); + } } diff --git a/rust/lithe-core/src/project_generators/commands.rs b/rust/lithe-core/src/project_generators/commands.rs index 425eb1cc13..5d1b311b36 100644 --- a/rust/lithe-core/src/project_generators/commands.rs +++ b/rust/lithe-core/src/project_generators/commands.rs @@ -199,9 +199,15 @@ pub(crate) fn project_check_name_command(payload: Value) -> Result Result { let payload: ExtractPayload = decode_payload(payload, "project.extractArchive")?; + let result = match archive::extract(&payload.archive_path, &payload.destination) { + Ok(files) => Ok(files), + Err(archive::ExtractionError::Invalid(rejection)) => Err(rejection), + // The extractor has closed its descriptors. Keep the first typed error: + // a second cancellation check would reclassify a timeout as cancellation. + Err(archive::ExtractionError::Interrupted(error)) => return Err(error), + }; Ok(outcome( - archive::extract(&payload.archive_path, &payload.destination) - .map(|files| ("extracted", json!({"files": files}))), + result.map(|files| ("extracted", json!({"files": files}))), )) } diff --git a/rust/lithe-core/src/tests/build_systems.rs b/rust/lithe-core/src/tests/build_systems.rs index f3f73b3c00..939b1863dc 100644 --- a/rust/lithe-core/src/tests/build_systems.rs +++ b/rust/lithe-core/src/tests/build_systems.rs @@ -433,6 +433,33 @@ fn detection_applies_nesting_priority_and_rejections() { assert_eq!(gradle["markers"], json!(["android/settings.gradle.kts"])); } +#[test] +fn detection_returns_empty_for_non_directory_and_missing_workspace_roots() { + let workspace = TemporaryWorkspace::new("build-detect-non-directory"); + workspace.write( + "Cargo.toml", + "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n", + ); + for root in [ + workspace.0.join("Cargo.toml"), + workspace.0.join("missing"), + workspace.0.join("Cargo.toml/missing"), + ] { + let outcome = build_detect_command(json!({ + "workspaceRoot": root.to_string_lossy(), + "plugins": [{"manifest": fixture("build-static-file.plugin.json")}], + })) + .expect("the absolute detection request is well formed"); + assert_eq!(outcome["status"], "ok", "{}: {outcome}", root.display()); + assert_eq!( + outcome["detection"], + json!({"instances": [], "rejected": [], "shadowed": []}), + "{}", + root.display(), + ); + } +} + /// Maven's model comes from the host integration during detection, with the /// reactor's modules as projects and its profiles as option values. #[test] diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index 5a1373f805..ffab9cb121 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -2,12 +2,16 @@ param( [ValidateSet("Debug", "Release")] [string]$Configuration = "Debug", - [string]$RustTarget = "x86_64-pc-windows-msvc" + [string]$RustTarget = "x86_64-pc-windows-msvc", + [ValidateRange(1, 1200)][int]$InstallTimeoutSeconds = 300, + [string]$NativeBunPath = "" ) $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot $windowsApp = Join-Path $root "windows/tauri" +. (Join-Path $PSScriptRoot "windows-build-preflight.ps1") +Initialize-WindowsNativeBun -RepositoryRoot $root -NativeBunPath $NativeBunPath $bundledExtensionsSource = [System.IO.Path]::GetFullPath((Join-Path $windowsApp "src/extensions/bundled")) if (-not (Test-Path -LiteralPath $bundledExtensionsSource -PathType Container)) { throw "Bundled extensions source directory is missing: $bundledExtensionsSource" @@ -48,7 +52,7 @@ $env:RUSTUP_TOOLCHAIN = $rustToolchain & rustup target add $RustTarget --toolchain $rustToolchain if ($LASTEXITCODE -ne 0) { throw "Could not install Rust target $RustTarget" } -& (Join-Path $root "scripts/install-windows-frontend-dependencies.ps1") +& (Join-Path $root "scripts/install-windows-frontend-dependencies.ps1") -InstallTimeoutSeconds $InstallTimeoutSeconds & bun run typecheck if ($LASTEXITCODE -ne 0) { throw "Windows frontend type check failed" } diff --git a/scripts/check-plugin-fixture-coverage.mjs b/scripts/check-plugin-fixture-coverage.mjs new file mode 100644 index 0000000000..4e88c6a833 --- /dev/null +++ b/scripts/check-plugin-fixture-coverage.mjs @@ -0,0 +1,318 @@ +#!/usr/bin/env node +// Fixture coverage detector for shared/contracts/plugins/v1/fixtures. +// +// Every fixture file must appear exactly once in the explicit classification +// list scripts/plugin-fixture-coverage.json, and every list entry must point +// at a real file. This closes the "fixtures added without classification" +// hole: a new file in the directory fails with unknown-fixture, a stale list +// entry fails with missing-fixture, a file classified twice fails with +// duplicate-entry. The list must stay sorted so diffs stay reviewable, and a +// non-wired Windows status is a known gap that must name a roadmap stage +// (M0-M10) and a concrete detail until a real test closes it. +// +// Usage: +// node scripts/check-plugin-fixture-coverage.mjs +// [--manifest scripts/plugin-fixture-coverage.json] +// [--repo-root ] [--fixtures-dir ] +// +// Exits 0 when the enumeration and the list match and every entry is +// coherent; exits 1 after printing one line per problem. Read-only: it never +// writes anything and needs no network. Unit tests live in +// scripts/check-plugin-fixture-coverage.test.mjs. +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; + +export const CATEGORY_VALUES = [ + 'valid-manifest', + 'invalid-edits', + 'command-plans', + 'build-plans', + 'runtime-versions', + 'function-protocol', + 'project-plans', + 'project-generation', + 'marketplace', + // Shared acceptance data for publisher-signature verification (M1); the + // fixture and its classification ship together in the unified plugin PR. + // Both hosts consume it in their own signature tests, so there is no Core + // validation surface and coreEvidence is optional for this category. + 'signature-vectors', + 'native-ui', + 'vcs-provider', + 'template-assets', +]; + +export const OUTCOME_VALUES = ['success', 'rejection', 'partial-support']; + +export const WINDOWS_STATUS_VALUES = ['wired', 'validated-only', 'indirect-core', 'unwired']; + +// Roadmap stages M0-M10, plus `followup` for capabilities that landed after +// the roadmap froze: the plan routes those into separately numbered later +// stages (section 6) that do not exist yet, so no M-number may be invented. +export const GAP_STAGE_PATTERN = /^(M(10|[0-9])|followup)$/; + +export function defaultRepoRoot() { + return join(dirname(fileURLToPath(import.meta.url)), '..'); +} + +export function defaultManifestPath() { + return join(defaultRepoRoot(), 'scripts', 'plugin-fixture-coverage.json'); +} + +// Recursively lists regular files under fixturesDir as '/'-separated paths +// relative to it, in the same byte order the manifest must be sorted with. +// Any non-directory, non-regular entry (symlink, junction) is reported as a +// fixture candidate so an anomalous entry cannot hide behind the enumeration. +export function enumerateFixtureFiles(fixturesDir) { + if (!statSync(fixturesDir, { throwIfNoEntry: false })?.isDirectory()) { + throw new Error(`fixtures directory is not readable: ${fixturesDir}`); + } + const found = []; + const walk = (absolute, prefix) => { + for (const entry of readdirSync(absolute, { withFileTypes: true })) { + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + walk(join(absolute, entry.name), relativePath); + } else { + found.push(relativePath); + } + } + }; + walk(fixturesDir, ''); + found.sort(comparePaths); + return found; +} + +function comparePaths(left, right) { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +// An evidence string is '[:] '. +// Only the file part is enforced (test files move); the line number is +// documentation of where the evidence was read at the audit's base commit. +export function evidenceFilePath(evidence) { + const token = evidence.trim().split(/\s+/)[0] ?? ''; + const match = token.match(/^(.+):(\d+)$/); + return match ? match[1] : token; +} + +// Checks the classification list against the real directory enumeration. +// Returns { ok, problems, summary }; problems are { kind, detail } and the +// summary counts fixtures, categories, wired entries, and known gaps. +export function runCoverageCheck({ repoRoot, fixturesDir, manifest }) { + const problems = []; + const push = (kind, detail) => problems.push({ kind, detail }); + + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + return { + ok: false, + problems: [{ kind: 'io', detail: 'manifest is not a JSON object' }], + summary: null, + }; + } + if (manifest.schemaVersion !== 1) { + push('io', `manifest schemaVersion must be 1, got ${JSON.stringify(manifest.schemaVersion)}`); + } + const fixtures = manifest.fixtures; + if (!Array.isArray(fixtures)) { + push('io', 'manifest fixtures must be an array'); + return { ok: false, problems, summary: null }; + } + + let onDisk; + try { + onDisk = enumerateFixtureFiles(fixturesDir); + } catch (error) { + push('io', String(error.message ?? error)); + return { ok: false, problems, summary: null }; + } + + const byPath = new Map(); + let previousPath = null; + for (const [index, entry] of fixtures.entries()) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + push('invalid-entry', `fixtures[${index}] is not an object`); + continue; + } + const path = entry.path; + if (typeof path !== 'string' || path.length === 0) { + push('invalid-entry', `fixtures[${index}].path must be a non-empty string`); + continue; + } + if (byPath.has(path)) { + push('duplicate-entry', `${path} is classified at indexes ${byPath.get(path)} and ${index}`); + continue; + } + byPath.set(path, index); + if (previousPath !== null && comparePaths(previousPath, path) >= 0) { + push('unsorted-entry', `${path} must come after ${previousPath}; keep the list sorted`); + } + previousPath = path; + validateEntry(entry, repoRoot, push); + } + + const listed = new Set(byPath.keys()); + for (const path of onDisk) { + if (!listed.has(path)) push('unknown-fixture', `${path} exists in the fixtures directory but is not classified`); + } + for (const path of listed) { + if (!onDisk.includes(path)) push('missing-fixture', `${path} is classified but does not exist in the fixtures directory`); + } + + const classified = [...byPath.keys()]; + const summary = { + fixtures: classified.length, + onDisk: onDisk.length, + byCategory: {}, + wired: 0, + knownGaps: 0, + gapStages: {}, + }; + for (const path of classified) { + const entry = fixtures[byPath.get(path)]; + if (!entry || typeof entry !== 'object') continue; + summary.byCategory[entry.category] = (summary.byCategory[entry.category] ?? 0) + 1; + if (entry.windowsStatus === 'wired') summary.wired += 1; + else { + summary.knownGaps += 1; + if (typeof entry.gapStage === 'string') { + summary.gapStages[entry.gapStage] = (summary.gapStages[entry.gapStage] ?? 0) + 1; + } + } + } + + return { ok: problems.length === 0, problems, summary }; +} + +function validateEntry(entry, repoRoot, push) { + const path = entry.path; + if (typeof path === 'string' && path.length > 0) { + if (path.includes('\\') || path.startsWith('/') || path.split('/').includes('..')) { + push('invalid-entry', `${path}: fixture paths must be '/'-separated and stay inside the fixtures directory`); + } + } + if (!CATEGORY_VALUES.includes(entry.category)) { + push('invalid-entry', `${path ?? ''}: unknown category ${JSON.stringify(entry.category)}`); + } + if (!OUTCOME_VALUES.includes(entry.outcome)) { + push('invalid-entry', `${path ?? ''}: unknown outcome ${JSON.stringify(entry.outcome)}`); + } + if (!WINDOWS_STATUS_VALUES.includes(entry.windowsStatus)) { + push('invalid-entry', `${path ?? ''}: unknown windowsStatus ${JSON.stringify(entry.windowsStatus)}`); + } + checkEvidence(entry.path, entry.coreEvidence, 'coreEvidence', repoRoot, push, entry.category !== 'signature-vectors'); + checkEvidence(entry.path, entry.windowsEvidence, 'windowsEvidence', repoRoot, push, true); + + const wired = entry.windowsStatus === 'wired'; + if (wired) { + if (!Array.isArray(entry.windowsEvidence) || entry.windowsEvidence.length === 0) { + push('invalid-entry', `${path}: windowsStatus "wired" requires at least one windowsEvidence entry`); + } + if (entry.gapStage !== null && entry.gapStage !== undefined) { + push('invalid-entry', `${path}: a wired fixture must not carry gapStage`); + } + if (entry.gapDetail !== null && entry.gapDetail !== undefined) { + push('invalid-entry', `${path}: a wired fixture must not carry gapDetail`); + } + } else if (WINDOWS_STATUS_VALUES.includes(entry.windowsStatus)) { + if (typeof entry.gapStage !== 'string' || !GAP_STAGE_PATTERN.test(entry.gapStage)) { + push('invalid-entry', `${path}: a known gap must name a roadmap stage (M0-M10), got ${JSON.stringify(entry.gapStage)}`); + } + if (typeof entry.gapDetail !== 'string' || entry.gapDetail.trim().length === 0) { + push('invalid-entry', `${path}: a known gap must describe what is missing in gapDetail`); + } + } +} + +function checkEvidence(path, evidence, field, repoRoot, push, required) { + if (evidence === undefined) { + if (required) push('invalid-entry', `${path ?? ''}: ${field} is required`); + return; + } + if (!Array.isArray(evidence)) { + push('invalid-entry', `${path ?? ''}: ${field} must be an array of strings`); + return; + } + if (required && field === 'coreEvidence' && evidence.length === 0) { + push('invalid-entry', `${path}: coreEvidence must name at least one Core-side reference`); + return; + } + for (const item of evidence) { + if (typeof item !== 'string' || item.trim().length === 0) { + push('invalid-entry', `${path ?? ''}: ${field} entries must be non-empty strings`); + continue; + } + const file = evidenceFilePath(item); + if (file.length === 0) { + push('invalid-entry', `${path}: ${field} entry does not start with a file path: ${item}`); + continue; + } + if (!existsSync(join(repoRoot, ...file.split('/')))) { + push('evidence-path', `${path}: ${field} references a missing file: ${file}`); + } + } +} + +// Fixture-set drift is the failure CI cannot fix by rerunning; point the +// person reading a red check at the one file that resolves it. +const FIXTURE_SET_PROBLEMS = new Set(['unknown-fixture', 'missing-fixture', 'duplicate-entry', 'unsorted-entry']); + +function printReport(result) { + const { problems, summary } = result; + for (const problem of problems) console.error(`${problem.kind}: ${problem.detail}`); + if (problems.some((problem) => FIXTURE_SET_PROBLEMS.has(problem.kind))) { + console.error('hint: register or clean up fixtures in scripts/plugin-fixture-coverage.json; the maintenance rules are section 6 of docs/development/windows-plugin-fixture-coverage.md'); + } + if (summary) { + const stages = Object.entries(summary.gapStages) + .sort(comparePaths) + .map(([stage, count]) => `${stage}:${count}`) + .join(' '); + console.log( + `fixtures=${summary.fixtures} onDisk=${summary.onDisk} wired=${summary.wired} knownGaps=${summary.knownGaps}` + + (stages ? ` gapStages(${stages})` : ''), + ); + } +} + +function main() { + const { values } = parseArgs({ + options: { + manifest: { type: 'string' }, + 'repo-root': { type: 'string' }, + 'fixtures-dir': { type: 'string' }, + }, + }); + const repoRoot = values['repo-root'] ?? defaultRepoRoot(); + const manifestPath = values.manifest ?? join(repoRoot, 'scripts', 'plugin-fixture-coverage.json'); + + let manifest; + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + } catch (error) { + console.error(`io: cannot read manifest ${manifestPath}: ${error.message}`); + process.exitCode = 1; + return; + } + + const fixturesDir = + values['fixtures-dir'] ?? + (typeof manifest.fixturesDirectory === 'string' ? join(repoRoot, ...manifest.fixturesDirectory.split('/')) : null); + if (!fixturesDir) { + console.error('io: manifest fixturesDirectory is missing and --fixtures-dir was not given'); + process.exitCode = 1; + return; + } + + const result = runCoverageCheck({ repoRoot, fixturesDir, manifest }); + printReport(result); + process.exitCode = result.ok ? 0 : 1; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/scripts/check-plugin-fixture-coverage.test.mjs b/scripts/check-plugin-fixture-coverage.test.mjs new file mode 100644 index 0000000000..5344aa434a --- /dev/null +++ b/scripts/check-plugin-fixture-coverage.test.mjs @@ -0,0 +1,404 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + defaultManifestPath, + defaultRepoRoot, + enumerateFixtureFiles, + evidenceFilePath, + GAP_STAGE_PATTERN, + runCoverageCheck, +} from './check-plugin-fixture-coverage.mjs'; + +// A throwaway repository shaped like the real one: a fixtures directory, a +// classification manifest, and evidence files the manifest can point at. +// Every wait is synchronous filesystem work, so the tests are deterministic +// and clean their temp roots through t.after on success and failure alike. +function tempRepo(t) { + const root = mkdtempSync(join(tmpdir(), 'lithe-fixture-coverage-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const fixturesDirectory = 'shared/contracts/plugins/v1/fixtures'; + const fixturesDir = join(root, ...fixturesDirectory.split('/')); + return { + root, + fixturesDir, + fixturesDirectory, + writeFixture(relativePath) { + mkdirSync(dirname(join(fixturesDir, ...relativePath.split('/'))), { recursive: true }); + writeFileSync(join(fixturesDir, ...relativePath.split('/')), '{}\n'); + }, + writeFile(relativePath, text) { + mkdirSync(dirname(join(root, ...relativePath.split('/'))), { recursive: true }); + writeFileSync(join(root, ...relativePath.split('/')), text); + }, + writeManifest(entries, extra = {}) { + const manifestPath = join(root, 'scripts', 'plugin-fixture-coverage.json'); + mkdirSync(dirname(manifestPath), { recursive: true }); + writeFileSync( + manifestPath, + JSON.stringify({ schemaVersion: 1, fixturesDirectory, fixtures: entries, ...extra }, null, 2), + ); + }, + check() { + const manifest = JSON.parse(readFileSync(join(root, 'scripts', 'plugin-fixture-coverage.json'), 'utf8')); + return runCoverageCheck({ repoRoot: root, fixturesDir, manifest }); + }, + }; +} + +// Evidence strings are repo-relative, like the real manifest's. +const fixtureEvidence = (relativePath) => `shared/contracts/plugins/v1/fixtures/${relativePath}:1 exists`; + +function entry(path, overrides = {}) { + const evidence = [fixtureEvidence(path)]; + return { + path, + category: 'valid-manifest', + outcome: 'success', + coreEvidence: evidence, + windowsEvidence: evidence, + windowsStatus: 'wired', + gapStage: null, + gapDetail: null, + ...overrides, + }; +} + +const kinds = (result) => result.problems.map((problem) => problem.kind); + +test('a matching manifest and directory pass with an honest summary', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.plugin.json'); + repo.writeFixture('nested/b.json'); + repo.writeManifest([ + entry('a.plugin.json'), + entry('nested/b.json', { + category: 'marketplace', + windowsStatus: 'unwired', + gapStage: 'M2', + gapDetail: 'no Windows consumer yet', + windowsEvidence: [], + }), + ]); + const result = repo.check(); + assert.equal(result.ok, true); + assert.deepEqual(result.problems, []); + assert.equal(result.summary.fixtures, 2); + assert.equal(result.summary.wired, 1); + assert.equal(result.summary.knownGaps, 1); + assert.deepEqual(result.summary.gapStages, { M2: 1 }); + assert.deepEqual(result.summary.byCategory, { 'valid-manifest': 1, marketplace: 1 }); +}); + +test('a new file in the fixtures directory fails as unknown', (t) => { + const repo = tempRepo(t); + repo.writeFixture('classified.json'); + repo.writeFixture('surprise.plugin.json'); + repo.writeManifest([entry('classified.json')]); + const result = repo.check(); + assert.equal(result.ok, false); + assert.deepEqual(kinds(result), ['unknown-fixture']); + assert.match(result.problems[0].detail, /surprise\.plugin\.json/); +}); + +test('a classified file that no longer exists fails as missing', (t) => { + const repo = tempRepo(t); + repo.writeFixture('present.json'); + repo.writeManifest([ + entry('present.json'), + // Its evidence points at a surviving file so only the missing fixture is reported. + entry('removed.json', { + coreEvidence: [fixtureEvidence('present.json')], + windowsEvidence: [fixtureEvidence('present.json')], + }), + ]); + const result = repo.check(); + assert.equal(result.ok, false); + assert.deepEqual(kinds(result), ['missing-fixture']); + assert.match(result.problems[0].detail, /removed\.json/); +}); + +test('classifying the same fixture twice fails', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeManifest([entry('a.json'), entry('a.json')]); + const result = repo.check(); + assert.equal(result.ok, false); + assert.deepEqual(kinds(result), ['duplicate-entry']); +}); + +test('an unsorted list fails so diffs stay reviewable', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeFixture('b.json'); + repo.writeManifest([entry('b.json'), entry('a.json')]); + const result = repo.check(); + assert.equal(result.ok, false); + assert.deepEqual(kinds(result), ['unsorted-entry']); +}); + +test('incoherent entries fail: unknown enums, wired without evidence, gap without stage', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeFixture('b.json'); + repo.writeFixture('c.json'); + repo.writeFixture('d.json'); + repo.writeManifest([ + entry('a.json', { category: 'mystery', windowsStatus: 'indirect-core', gapStage: 'M4', gapDetail: 'x', windowsEvidence: [] }), + entry('b.json', { windowsEvidence: [] }), + entry('c.json', { windowsStatus: 'indirect-core', gapDetail: 'stage forgotten', windowsEvidence: [] }), + entry('d.json', { coreEvidence: [] }), + ]); + const result = repo.check(); + assert.equal(result.ok, false); + const details = result.problems.map((problem) => problem.detail); + assert.ok(details.some((detail) => detail.includes('unknown category "mystery"')), details.join('\n')); + assert.ok(details.some((detail) => detail.includes('b.json: windowsStatus "wired" requires')), details.join('\n')); + assert.ok(details.some((detail) => detail.includes('c.json: a known gap must name a roadmap stage')), details.join('\n')); + assert.ok(details.some((detail) => detail.includes('d.json: coreEvidence must name')), details.join('\n')); +}); + +test('a wired fixture must not carry gap fields and a gap must describe the miss', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeFixture('b.json'); + repo.writeManifest([ + entry('a.json', { gapStage: 'M4', gapDetail: 'stray' }), + entry('b.json', { windowsStatus: 'unwired', gapStage: 'M4', gapDetail: ' ' }), + ]); + const result = repo.check(); + assert.equal(result.ok, false); + const details = result.problems.map((problem) => problem.detail); + assert.ok(details.some((detail) => detail.includes('a.json: a wired fixture must not carry gapStage')), details.join('\n')); + assert.ok(details.some((detail) => detail.includes('b.json: a known gap must describe')), details.join('\n')); +}); + +test('evidence must point at files that exist in the repository', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeManifest([ + entry('a.json', { + coreEvidence: ['rust/lithe-core/src/tests/gone.rs:12 vanished'], + windowsEvidence: [fixtureEvidence('a.json')], + }), + ]); + const result = repo.check(); + assert.equal(result.ok, false); + assert.deepEqual(kinds(result), ['evidence-path']); + assert.match(result.problems[0].detail, /rust\/lithe-core\/src\/tests\/gone\.rs/); +}); + +test('fixture paths must stay inside the fixtures directory', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeManifest([entry('../outside.json')]); + const result = repo.check(); + assert.equal(result.ok, false); + assert.ok(kinds(result).includes('invalid-entry'), kinds(result).join(', ')); + assert.ok(result.problems.some((problem) => /stay inside the fixtures directory/.test(problem.detail))); + // The escaping path must not silently pass the set comparison either. + assert.ok(kinds(result).includes('unknown-fixture'), 'a.json on disk goes unclassified'); + assert.ok(kinds(result).includes('missing-fixture'), 'the escaped entry matches no file'); +}); + +test('a missing fixtures directory is an io problem, not a crash', (t) => { + const root = mkdtempSync(join(tmpdir(), 'lithe-fixture-coverage-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const result = runCoverageCheck({ + repoRoot: root, + fixturesDir: join(root, 'does-not-exist'), + manifest: { schemaVersion: 1, fixtures: [] }, + }); + assert.equal(result.ok, false); + assert.deepEqual(kinds(result), ['io']); + assert.equal(result.summary, null); +}); + +test('a non-object manifest is an io problem', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + const result = runCoverageCheck({ repoRoot: repo.root, fixturesDir: repo.fixturesDir, manifest: [] }); + assert.equal(result.ok, false); + assert.deepEqual(kinds(result), ['io']); +}); + +test('enumeration is recursive, uses / separators, and is sorted', (t) => { + const repo = tempRepo(t); + repo.writeFixture('zeta.json'); + repo.writeFixture('nested/deep/gamma.json'); + repo.writeFixture('alpha.json'); + assert.deepEqual(enumerateFixtureFiles(repo.fixturesDir), [ + 'alpha.json', + 'nested/deep/gamma.json', + 'zeta.json', + ]); +}); + +test('evidence parsing keeps the file part and drops the line number', () => { + assert.equal(evidenceFilePath('rust/lithe-core/src/tests/vcs.rs:65 a name'), 'rust/lithe-core/src/tests/vcs.rs'); + assert.equal(evidenceFilePath('windows/tauri/src-tauri/src/plugin_host/tests.rs name'), 'windows/tauri/src-tauri/src/plugin_host/tests.rs'); + assert.equal(evidenceFilePath('shared/contracts/plugins/v1/fixtures/x.json'), 'shared/contracts/plugins/v1/fixtures/x.json'); +}); + +// The enforcement test: the real manifest must match the real fixtures +// directory right now, and every non-wired entry must keep an honest gap +// classification. Completeness is never asserted here - the day every +// fixture is genuinely wired on Windows this test must still pass; the +// all-wired positive case below proves that path exists. +test('the real fixture directory and classification list agree', () => { + const repoRoot = defaultRepoRoot(); + const manifest = JSON.parse(readFileSync(defaultManifestPath(), 'utf8')); + const fixturesDir = join(repoRoot, ...manifest.fixturesDirectory.split('/')); + const result = runCoverageCheck({ repoRoot, fixturesDir, manifest }); + assert.equal(result.ok, true, result.problems.map((problem) => `${problem.kind}: ${problem.detail}`).join('\n')); + assert.ok(result.summary.fixtures >= 30, `expected the full fixture set, got ${result.summary.fixtures}`); + for (const fixture of manifest.fixtures) { + if (fixture.windowsStatus !== 'wired') { + assert.match(fixture.gapStage, GAP_STAGE_PATTERN, `${fixture.path} gap stage`); + assert.ok(fixture.gapDetail.trim().length > 0, `${fixture.path} gap detail`); + } + } +}); + +// The anti-forgery stance is honesty, not permanent partiality: a manifest +// that wires every fixture with real evidence must pass with zero known +// gaps, so the gate can never be satisfied by keeping statuses partial. +test('a fully wired manifest with valid evidence passes with zero known gaps', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeFixture('b.json'); + repo.writeManifest([entry('a.json'), entry('b.json')]); + const result = repo.check(); + assert.equal(result.ok, true, result.problems.map((problem) => `${problem.kind}: ${problem.detail}`).join('\n')); + assert.equal(result.summary.knownGaps, 0); + assert.equal(result.summary.wired, 2); +}); + +test('M0 and followup are legal gap stages, signature-vectors a legal category, M11 is not', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeFixture('b.json'); + repo.writeFixture('c.json'); + repo.writeManifest([ + entry('a.json', { + category: 'signature-vectors', + windowsStatus: 'validated-only', + gapStage: 'M0', + gapDetail: 'baseline problem owned by an M0 focused fix PR', + windowsEvidence: [], + }), + entry('b.json', { windowsStatus: 'unwired', gapStage: 'M0', gapDetail: 'also owned by M0', windowsEvidence: [] }), + entry('c.json', { + windowsStatus: 'unwired', + gapStage: 'followup', + gapDetail: 'capability landed after the roadmap froze; awaiting its numbered stage', + windowsEvidence: [], + }), + ]); + const result = repo.check(); + assert.equal(result.ok, true, result.problems.map((problem) => `${problem.kind}: ${problem.detail}`).join('\n')); + assert.equal(result.summary.gapStages.M0, 2); + assert.equal(result.summary.gapStages.followup, 1); + + repo.writeManifest([ + entry('a.json', { windowsStatus: 'unwired', gapStage: 'M11', gapDetail: 'beyond the roadmap', windowsEvidence: [] }), + ]); + const rejected = repo.check(); + assert.equal(rejected.ok, false); + assert.ok(rejected.problems.some((problem) => /must name a roadmap stage/.test(problem.detail))); +}); + +// Signature vectors are shared acceptance data consumed by each host's own +// signature tests; there is no Core validation surface, so coreEvidence may +// be empty as long as the Windows side carries real evidence. +test('signature-vectors entries may carry host-side evidence only', (t) => { + const repo = tempRepo(t); + repo.writeFixture('signature-vectors.json'); + // The Windows signature test file the evidence points at must exist in the + // throwaway repository, mirroring the real M1 tree. + repo.writeFile('windows/tauri/src-tauri/src/plugin_host/signature.rs', 'mod tests {}'); + repo.writeManifest([ + entry('signature-vectors.json', { + category: 'signature-vectors', + outcome: 'success', + coreEvidence: [], + windowsEvidence: ['windows/tauri/src-tauri/src/plugin_host/signature.rs:663 shared_fixture_verifies_end_to_end'], + }), + ]); + const result = repo.check(); + assert.equal(result.ok, true, result.problems.map((problem) => `${problem.kind}: ${problem.detail}`).join('\n')); + + // The exemption must not become a hole: other categories still require + // Core-side evidence even when Windows evidence is present. + repo.writeFixture('plain.json'); + repo.writeManifest([ + entry('signature-vectors.json', { + category: 'signature-vectors', + coreEvidence: [], + windowsEvidence: ['windows/tauri/src-tauri/src/plugin_host/signature.rs:663 shared_fixture_verifies_end_to_end'], + }), + entry('plain.json', { coreEvidence: [], windowsEvidence: [fixtureEvidence('plain.json')] }), + ]); + const rejected = repo.check(); + assert.equal(rejected.ok, false); + assert.ok(rejected.problems.some((problem) => /plain\.json: coreEvidence must name/.test(problem.detail))); +}); + +// CI wiring guard: the Linux changes lane must invoke both the detector CLI +// and this test file. If the workflow stops calling them, a new fixture can +// land unregistered without any real pull request check failing. +test('the Linux changes lane wires the detector and its tests into CI', () => { + const workflow = readFileSync(join(defaultRepoRoot(), '.github', 'workflows', 'ci-macos.yml'), 'utf8'); + assert.ok(workflow.includes('run: node scripts/check-plugin-fixture-coverage.mjs'), 'detector CLI gate step missing'); + assert.ok(workflow.includes('scripts/check-plugin-fixture-coverage.test.mjs'), 'test invocation missing'); + assert.ok(workflow.includes('--test-timeout=15000'), 'per-test deadline missing'); + assert.ok(workflow.includes('plugin-fixture-coverage.junit.xml'), 'junit timing evidence missing'); +}); + +// CI runs the CLI, not the library function, so the executable boundary +// itself gets covered: exit codes, the summary line, and the registration +// hint on fixture-set drift. Every invocation has a hard deadline. +const cliPath = fileURLToPath(new URL('./check-plugin-fixture-coverage.mjs', import.meta.url)); + +function runCli(arguments_, { cwd } = {}) { + try { + return { + status: 0, + stdout: execFileSync(process.execPath, [cliPath, ...arguments_], { encoding: 'utf8', timeout: 10000, cwd }), + }; + } catch (error) { + return { status: error.status ?? 1, stdout: error.stdout ?? '', stderr: error.stderr ?? '' }; + } +} + +test('the CLI exits zero with a summary on a clean tree', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeManifest([entry('a.json')]); + const run = runCli([ + '--repo-root', repo.root, + '--manifest', join(repo.root, 'scripts', 'plugin-fixture-coverage.json'), + '--fixtures-dir', repo.fixturesDir, + ]); + assert.equal(run.status, 0, run.stderr); + assert.match(run.stdout, /fixtures=1 onDisk=1 wired=1 knownGaps=0/); +}); + +test('the CLI exits nonzero, names the problem, and points at the manifest', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeFixture('unregistered.json'); + repo.writeManifest([entry('a.json')]); + const run = runCli([ + '--repo-root', repo.root, + '--manifest', join(repo.root, 'scripts', 'plugin-fixture-coverage.json'), + '--fixtures-dir', repo.fixturesDir, + ]); + assert.equal(run.status, 1); + assert.match(run.stderr, /unknown-fixture: unregistered\.json/); + assert.match(run.stderr, /hint: register or clean up fixtures in scripts\/plugin-fixture-coverage\.json/); +}); diff --git a/scripts/invoke-windows-bun-install.ps1 b/scripts/invoke-windows-bun-install.ps1 index af896ebcfe..47d12895a0 100644 --- a/scripts/invoke-windows-bun-install.ps1 +++ b/scripts/invoke-windows-bun-install.ps1 @@ -87,8 +87,15 @@ $installJob = [LitheBunInstallJob]::AttachWorker() [LitheBunInstallJob]::StartDeadline($TimeoutSeconds) $arguments = @("install", "--frozen-lockfile") if ($NoCache) { $arguments += "--no-cache" } -& bun @arguments -$installExitCode = $LASTEXITCODE +$originalNodeOptions = $env:NODE_OPTIONS +try { + $proxyPreload = (Join-Path $PSScriptRoot "windows-tree-sitter-proxy.cjs").Replace('\', '/') + $env:NODE_OPTIONS = ($originalNodeOptions + ' --require "' + $proxyPreload + '"').Trim() + & bun @arguments + $installExitCode = $LASTEXITCODE +} finally { + $env:NODE_OPTIONS = $originalNodeOptions +} [LitheBunInstallJob]::FinishDeadline() [GC]::KeepAlive($installJob) exit $installExitCode diff --git a/scripts/invoke-windows-tauri-build.ps1 b/scripts/invoke-windows-tauri-build.ps1 index 705fc420f8..6dab89c5f9 100644 --- a/scripts/invoke-windows-tauri-build.ps1 +++ b/scripts/invoke-windows-tauri-build.ps1 @@ -22,19 +22,22 @@ function Write-CacheWarning { Push-Location $windowsApp try { - & bunx @TauriArguments - if ($LASTEXITCODE -eq 0) { return } + . (Join-Path $PSScriptRoot "windows-build-preflight.ps1") + Invoke-TauriWithOriginalManifestBytes -Manifest (Join-Path $windowsApp "src-tauri/Cargo.toml") -Action { + & bunx @TauriArguments + if ($LASTEXITCODE -eq 0) { return } - if ($env:LITHE_CARGO_BUILD_CACHE_RESTORED -ne "true") { - throw $FailureMessage - } + if ($env:LITHE_CARGO_BUILD_CACHE_RESTORED -ne "true") { + throw $FailureMessage + } - Write-CacheWarning "The Tauri build failed after restoring Cargo outputs. Clearing the repository target directory and retrying once without cached outputs." - if (Test-Path -LiteralPath $targetRoot) { - Remove-Item -Recurse -Force -LiteralPath $targetRoot + Write-CacheWarning "The Tauri build failed after restoring Cargo outputs. Clearing the repository target directory and retrying once without cached outputs." + if (Test-Path -LiteralPath $targetRoot) { + Remove-Item -Recurse -Force -LiteralPath $targetRoot + } + & bunx @TauriArguments + if ($LASTEXITCODE -ne 0) { throw "$FailureMessage after a clean retry" } } - & bunx @TauriArguments - if ($LASTEXITCODE -ne 0) { throw "$FailureMessage after a clean retry" } } finally { Pop-Location } diff --git a/scripts/native-workbench-browser.mjs b/scripts/native-workbench-browser.mjs new file mode 100644 index 0000000000..6a89be1084 --- /dev/null +++ b/scripts/native-workbench-browser.mjs @@ -0,0 +1,529 @@ +import path from "node:path"; + +export const WORKBENCH_STAGES = 8; + +// The boundary catalog is registered in the client before MainLayout is loaded, +// so Vite cannot scan that graph at server startup. Prebundle its third-party +// controls explicitly and disable discovery to prevent mid-workflow reloads. +export const WORKBENCH_OPTIMIZE_DEPS = { + noDiscovery: true, + include: [ + "@tauri-apps/api/mocks", + "react", + "react/jsx-runtime", + "react/jsx-dev-runtime", + "react-dom/client", + "zustand", + "zustand/vanilla", + "zustand/traditional", + "zustand/middleware", + "zustand/middleware/immer", + "immer", + "@base-ui/react", + "@base-ui/react/context-menu", + "@base-ui/react/use-render", + "@base-ui/react/checkbox", + "@base-ui/react/combobox", + "@base-ui/react/number-field", + "@base-ui/react/popover", + "@base-ui/react/select", + "@base-ui/react/separator", + "@base-ui/react/tabs", + "@base-ui/react/toggle-group", + "@base-ui/react/toggle", + "@tanstack/react-virtual", + "class-variance-authority", + "clsx", + "lucide-react", + "motion", + "motion/react", + "tailwind-merge", + ], +}; + +/** Drives only public DOM interactions. Evaluation observes boundary receipts, + * retains DOM identity, or controls external asynchronous completion gates. */ +export async function nativeWorkbenchRegression(page, step) { + const snapshots = []; + const observe = () => page.evaluate(() => window.nativeWorkbenchProbe.observe()); + const check = (value, message) => { + if (!value) throw new Error(message); + }; + const rail = (name) => page.getByRole("button", { name: `Panel ${name}`, exact: true }); + const input = (name) => page.getByRole("textbox", { name: `Name ${name}`, exact: true }); + const slot = (name) => page.locator(`[data-native-tool-window-slot="${name}"]`); + const wait = (predicate, arg) => page.waitForFunction(predicate, arg, { timeout: 5000 }); + const ready = (name) => + wait((name) => { + const input = document.querySelector(`input[aria-label="Name ${name}"]`); + return input && !input.disabled; + }, name); + const phase = async (name, action) => { + const begin = performance.now(); + try { + const observation = await step(name, action); + snapshots.push({ name, durationMs: performance.now() - begin, observation }); + } catch (error) { + let boundary; + try { + boundary = await step("observe failed workbench phase", observe, 2000); + } catch (observationError) { + boundary = { observationError: String(observationError) }; + } + throw new Error(`${name}: ${error.stack ?? error}; boundary=${JSON.stringify(boundary)}`); + } + }; + const edit = async (name, value) => { + await input(name).click(); + await input(name).press("ControlOrMeta+A"); + await page.keyboard.type(value); + check((await input(name).inputValue()) === value, `${name} draft missing`); + }; + const move = async (name, placement) => { + await rail(name).click({ button: "right" }); + await page.getByRole("menuitem", { name: placement, exact: true }).click(); + await slot(placement) + .getByRole("textbox", { name: `Name ${name}`, exact: true }) + .waitFor(); + await ready(name); + }; + const hold = (kind, id) => + page.evaluate(([kind, id]) => window.nativeWorkbenchProbe.hold(kind, id), [kind, id]); + const release = (kind, id) => + page.evaluate(([kind, id]) => window.nativeWorkbenchProbe.release(kind, id), [kind, id]); + let primaryError; + let cleanup; + try { + await phase("activity rail opens three production slots; input saves full values", async () => { + await page.getByTestId("terminal-session").waitFor({ state: "attached" }); + await page.evaluate(() => { + window.nativeWorkbenchTerminal = document.querySelector('[data-testid="terminal-session"]'); + }); + for (const name of ["left", "tree", "right"]) await rail(name).click(); + await ready("left"); + await ready("right"); + await page.getByRole("tree").waitFor(); + for (const name of ["left", "bottom", "right"]) + check((await slot(name).count()) === 1, `missing ${name} slot`); + await edit("left", "A left draft"); + await page.getByRole("button", { name: "Save left", exact: true }).click(); + await wait(() => + window.nativeWorkbenchProbe + .observe() + .prepared.some((p) => p.actionId === "save" && p.values?.name === "A left draft"), + ); + await ready("left"); + const state = await observe(); + check( + state.terminalMounts === 1 && state.terminalUnmounts === 0, + "terminal remounted on slot open", + ); + check(state.prepared.filter((p) => p.actionId === "save").length === 1, "save duplicated"); + return { + slots: ["left", "bottom", "right"], + fullValues: { name: "A left draft" }, + terminalMounts: state.terminalMounts, + }; + }); + await phase("tree keyboard selection and toolbar action use current selected ID", async () => { + const tree = page.getByRole("tree"); + await tree.click(); + for (const key of ["Home", "ArrowRight", "ArrowDown"]) await tree.press(key); + await page.getByRole("treeitem", { name: "Child", exact: true }).waitFor(); + check( + (await page + .getByRole("treeitem", { name: "Child", exact: true }) + .getAttribute("aria-selected")) === "true", + "child not selected", + ); + await page.getByRole("button", { name: "Run tree", exact: true }).click(); + await wait(() => + window.nativeWorkbenchProbe + .observe() + .prepared.some((p) => p.actionId === "save" && p.selectedId === "child"), + ); + await wait(() => + [...document.querySelectorAll("button")].some( + (button) => button.getAttribute("aria-label") === "Run tree" && !button.disabled, + ), + ); + const actionCount = (await observe()).prepared.filter( + (p) => p.actionId === "save" && p.selectedId === "child", + ).length; + check(actionCount === 1, "tree action duplicated"); + return { selectedId: "child", actionCount }; + }); + await phase( + "same-slot replacement and context-menu moves preserve the owning draft", + async () => { + await edit("right", "A right draft"); + await rail("other").click(); + await ready("other"); + check((await input("right").count()) === 0, "same-slot content remained mounted"); + await rail("right").click(); + await ready("right"); + check((await input("right").inputValue()) === "A right draft", "replacement lost draft"); + for (const placement of ["bottom", "left", "right"]) { + await move("right", placement); + check( + (await input("right").inputValue()) === "A right draft", + `move to ${placement} lost draft`, + ); + check( + (await page.getByTestId("native-plugin-panel").count()) === + (placement === "bottom" ? 2 : 1), + "move duplicated panel or failed to replace destination", + ); + } + return { moves: ["bottom", "left", "right"], draft: await input("right").inputValue() }; + }, + ); + await phase( + "close restores rail focus; hide and reopen retain draft and terminal DOM", + async () => { + await page.getByRole("button", { name: "Close native plugin panel", exact: true }).click(); + await slot("right").waitFor({ state: "detached" }); + check( + await rail("right").evaluate((e) => document.activeElement === e), + "close did not restore trigger focus", + ); + await rail("right").click(); + await ready("right"); + await rail("right").click(); + await slot("right").waitFor({ state: "detached" }); + await rail("right").click(); + await ready("right"); + check((await input("right").inputValue()) === "A right draft", "hide lost draft"); + check( + await page.evaluate( + () => + document.querySelector('[data-testid="terminal-session"]') === + window.nativeWorkbenchTerminal, + ), + "terminal DOM replaced", + ); + return { restoredFocus: true, retainedTerminal: true, retainedDraft: true }; + }, + ); + await phase( + "reverse open and late exact close cannot release the replacement owner", + async () => { + await rail("right").click(); + await slot("right").waitFor({ state: "detached" }); + const stale = `binding-${(await observe()).opened.length + 1}`; + await hold("open", stale); + await hold("close", stale); + await rail("right").click(); + await wait( + (id) => window.nativeWorkbenchProbe.observe().opened.some((b) => b.bindingId === id), + stale, + ); + await rail("other").click(); + await ready("other"); + await release("open", stale); + await rail("right").click(); + await ready("right"); + const current = (await observe()).opened.at(-1).bindingId; + await release("close", stale); + await wait((id) => window.nativeWorkbenchProbe.observe().closed.includes(id), stale); + const state = await observe(); + check( + state.owners.includes(current) && !state.owners.includes(stale), + "late close removed new owner", + ); + check(!state.prepared.some((p) => p.bindingId === stale), "stale open prepared work"); + check( + (await input("right").inputValue()) === "A right draft", + "reverse open changed draft", + ); + await page.getByRole("button", { name: "Save right", exact: true }).click(); + await wait( + (id) => + window.nativeWorkbenchProbe + .observe() + .prepared.some((p) => p.bindingId === id && p.actionId === "save"), + current, + ); + await ready("right"); + return { stale, current, stalePrepared: false, replacementActionable: true }; + }, + ); + await phase( + "controlled workspace lifecycle isolates B and restores A placement and values", + async () => { + await page.getByRole("tab", { name: "Workspace B", exact: true }).click(); + await input("right").waitFor({ state: "detached" }); + await rail("right").click(); + await ready("right"); + check((await input("right").inputValue()) === "initial", "B inherited A draft"); + await edit("right", "B right draft"); + await page.getByRole("tab", { name: "Workspace A", exact: true }).click(); + await ready("right"); + await wait( + () => document.querySelector('input[aria-label="Name right"]')?.value === "A right draft", + ); + check((await slot("right").count()) === 1, "A placement not restored"); + check( + (await page + .getByRole("tab", { name: "Workspace A", exact: true }) + .getAttribute("aria-selected")) === "true", + "production project tab did not select A", + ); + check( + await page.evaluate( + () => + document.querySelector('[data-testid="terminal-session"]') === + window.nativeWorkbenchTerminal, + ), + "workspace tabs replaced terminal mount identity", + ); + return { + boundary: "production ProjectTabBar; controlled filesystem switch API", + A: "A right draft", + B: "B right draft", + }; + }, + ); + await phase( + "package replacement cancels pending action; late prepare cleans only its captured receipt", + async () => { + const old = (await observe()).opened.at(-1).bindingId; + await hold("prepare", old); + await page.getByRole("button", { name: "Save right", exact: true }).click(); + await wait( + (id) => + window.nativeWorkbenchProbe + .observe() + .prepared.some((p) => p.bindingId === id && p.actionId === "save"), + old, + ); + const pending = (await observe()).prepared.findLast( + (p) => p.bindingId === old && p.actionId === "save", + ); + await page + .getByRole("button", { name: "External replace right package", exact: true }) + .click(); + await wait(() => + window.nativeWorkbenchProbe + .observe() + .opened.at(-1) + .entry.packageIdentity.endsWith("-replacement"), + ); + await ready("right"); + check( + (await input("right").inputValue()) === "initial", + "new package inherited old bytes' draft", + ); + await edit("right", "new package draft"); + const current = (await observe()).opened.at(-1).bindingId; + await release("prepare", old); + await wait( + (operation) => window.nativeWorkbenchProbe.observe().cancelled.includes(operation), + pending.operationId, + ); + await wait((id) => window.nativeWorkbenchProbe.observe().closed.includes(id), old); + const afterCancellation = await observe(); + check( + !afterCancellation.confirmed.includes(pending.operationId) && + !afterCancellation.reads.includes(pending.operationId), + "abandoned action executed after late prepare", + ); + await page.getByRole("button", { name: "Save right", exact: true }).click(); + await wait( + (id) => + window.nativeWorkbenchProbe + .observe() + .prepared.some( + (p) => + p.bindingId === id && + p.actionId === "save" && + p.values?.name === "new package draft", + ), + current, + ); + await ready("right"); + const afterReplacement = await observe(); + const currentAction = afterReplacement.prepared.findLast( + (p) => p.bindingId === current && p.actionId === "save", + ); + check( + afterReplacement.confirmed.includes(currentAction.operationId) && + afterReplacement.reads.includes(currentAction.operationId), + "replacement action did not confirm and read", + ); + check(afterReplacement.owners.includes(current), "old cancellation removed new owner"); + return { + old, + current, + cancelledOperation: pending.operationId, + abandonedActionExecuted: false, + replacementOperation: currentAction.operationId, + newPackageActionable: true, + }; + }, + ); + await phase( + "controlled window reconstruction isolates drafts; final close releases every exact owner", + async () => { + await page.getByRole("button", { name: "External host secondary", exact: true }).click(); + await wait(() => window.nativeWorkbenchProbe.observe().opened.at(-1).host === "secondary"); + await ready("right"); + check((await input("right").inputValue()) === "initial", "secondary inherited main draft"); + await edit("right", "secondary draft"); + await page.getByRole("button", { name: "External host main", exact: true }).click(); + await wait(() => window.nativeWorkbenchProbe.observe().opened.at(-1).host === "main"); + await ready("right"); + await wait(() => window.nativeWorkbenchProbe.observe().terminalMounts === 3); + check( + (await input("right").inputValue()) === "new package draft", + "main draft not restored", + ); + await page.getByRole("button", { name: "Close native plugin panel", exact: true }).click(); + await slot("right").waitFor({ state: "detached" }); + check( + await rail("right").evaluate((e) => document.activeElement === e), + "final close lost focus", + ); + await page.evaluate(() => window.nativeWorkbenchProbe.dispose()); + await wait(() => { + const s = window.nativeWorkbenchProbe.observe(); + return s.owners.length === 0 && s.closed.length === s.opened.length; + }); + const state = await observe(); + check(new Set(state.closed).size === state.opened.length, "binding closed twice or missed"); + check( + state.terminalMounts === 3 && state.terminalUnmounts === 3, + "unexpected terminal mount lifecycle", + ); + check(state.errors.length === 0, `external boundary errors: ${state.errors}`); + return { + boundary: "one Edge page reconstructs controlled host identity; not two product windows", + opened: state.opened.length, + closed: state.closed.length, + owners: state.owners, + terminalMounts: state.terminalMounts, + terminalUnmounts: state.terminalUnmounts, + }; + }, + ); + } catch (error) { + primaryError = error; + } finally { + try { + await step( + "dispose workbench fixture", + () => page.evaluate(() => window.nativeWorkbenchProbe.dispose()), + 5000, + ); + await wait(() => { + const s = window.nativeWorkbenchProbe.observe(); + return s.owners.length === 0 && s.closed.length === s.opened.length; + }); + cleanup = await step("observe workbench cleanup", observe, 2000); + } catch (error) { + const errors = [...(primaryError ? [primaryError] : []), error]; + primaryError = new AggregateError( + errors, + `Workbench or fixture cleanup failed: ${errors.map((error) => error.stack ?? error).join("\n")}`, + ); + } + } + if (primaryError) + throw Object.assign(primaryError, { workbenchEvidence: { snapshots, cleanup } }); + return { + snapshots, + cleanup, + scope: "real Edge MainLayout and public controls; controlled external lifecycle and native I/O", + }; +} + +// Workbench-only test doubles, shared with the isolated Bun suite. No product +// alias or capability gate is changed. The browser loads the shared fixture +// through Vite and registers its actual export names before importing MainLayout. +// This avoids executing client-only store dependencies through Vite's SSR runner. +export function workbenchBoundaryPlugin(app) { + const fixture = "/src/features/plugins/native-ui/native-main-layout.test-boundaries.tsx"; + const owner = path.join(app, "src/features/plugins/native-ui"); + const modules = new Map(); + const canonical = (id, importer) => { + if (id.startsWith("@/")) id = path.join(app, "src", id.slice(2)); + else if (id.startsWith(".")) id = path.resolve(path.dirname(importer ?? owner), id); + return id.replaceAll("\\", "/").replace(/\.(tsx?|jsx?)$/, ""); + }; + return { + name: "native-workbench-boundaries", + enforce: "pre", + configureServer(server) { + server.middlewares.use("/native-workbench-boundaries", (request, response) => { + if (request.method !== "POST") { + response.statusCode = 405; + response.end(); + return; + } + request.setTimeout(5000, () => request.destroy()); + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { + body += chunk; + if (Buffer.byteLength(body) > 65536) request.destroy(); + }); + request.on("end", () => { + try { + const catalog = JSON.parse(body); + if (!Array.isArray(catalog) || !catalog.length || catalog.length > 128) + throw new Error("Invalid workbench boundary catalog"); + const next = new Map(); + for (const entry of catalog) { + if (!Array.isArray(entry) || entry.length !== 2) + throw new Error("Invalid workbench boundary entry"); + const [id, names] = entry; + if ( + typeof id !== "string" || + !( + id.startsWith("@/") || + id.startsWith("../") || + ["sonner", "@tauri-apps/plugin-os", "@tauri-apps/api/window"].includes(id) + ) || + !Array.isArray(names) || + !names.length || + names.some((name) => typeof name !== "string" || !/^[A-Za-z_$][\w$]*$/.test(name)) + ) + throw new Error("Invalid workbench boundary exports"); + // Keep the real project tab entry path in Edge. Bun's existing + // suite still isolates this leaf while driving registry fixtures. + if (id === "@/features/window/components/project-tab-bar") continue; + next.set(canonical(id, path.join(owner, "native-main-layout.test-support.tsx")), { + id, + names, + }); + } + modules.clear(); + for (const [id, entry] of next) modules.set(id, entry); + response.statusCode = 204; + response.end(); + } catch (error) { + response.statusCode = 400; + response.end(String(error)); + } + }); + }); + }, + resolveId(id, importer) { + const key = canonical(id, importer); + if (modules.has(key)) return `\0native-workbench:${key}`; + }, + load(id) { + if (!id.startsWith("\0native-workbench:")) return; + const entry = modules.get(id.slice("\0native-workbench:".length)); + return [ + `import { workbenchBoundaryModules } from ${JSON.stringify(fixture)};`, + `const boundary = workbenchBoundaryModules.get(${JSON.stringify(entry.id)});`, + ...entry.names.map((name) => + name === "default" + ? "export default boundary.default;" + : `export const ${name} = boundary[${JSON.stringify(name)}];`, + ), + ].join("\n"); + }, + }; +} diff --git a/scripts/plugin-fixture-coverage.json b/scripts/plugin-fixture-coverage.json new file mode 100644 index 0000000000..33bca7a81c --- /dev/null +++ b/scripts/plugin-fixture-coverage.json @@ -0,0 +1,637 @@ +{ + "schemaVersion": 1, + "description": "Explicit classification of every file under shared/contracts/plugins/v1/fixtures. Checked by scripts/check-plugin-fixture-coverage.mjs (wired into the ci-macos.yml changes job on Linux): the directory enumeration and this list must match exactly. windowsStatus: wired = a product-path Windows test exercises this fixture; validated-only = only manifest validation/gating; indirect-core = Windows consumes it through Core commands without reading the file; unwired = no Windows list or test names it. A non-wired status is a known gap and must keep gapStage/gapDetail until a real test closes it; gapStage is a roadmap stage M0-M10 or 'followup' for capabilities that landed after the roadmap froze. Evidence strings are '[:] '; the checker verifies the file exists, lines are documentation as of base commit 1e78b59b6385fca6a881639b42048bf3bc587f4a synced with preview 83017afa851c83f119f40268bf9eb04b26aefc91 (backend capability).", + "fixturesDirectory": "shared/contracts/plugins/v1/fixtures", + "fixtures": [ + { + "path": "backend.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:32 BACKEND_FIXTURE", + "rust/lithe-core/src/tests/declarative_plugins.rs:615 plugin_backend_is_planned_without_a_workspace_and_names_its_host_services" + ], + "windowsEvidence": [], + "windowsStatus": "unwired", + "gapStage": "followup", + "gapDetail": "Windows 侧零引用。插件后端(backend:1)与函数协议 v2 是冻结路线图(2026-10-24 版 M0–M10)之后的新能力(preview 8471b998/#1470),macOS 宿主已声明 backend:[1](PluginTypes.swift:493);Windows 接入按计划 §6 单列后续阶段,暂无编号。" + }, + { + "path": "build-command-output-file.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/build_systems.rs:128 build_fixtures_validate", + "rust/lithe-core/src/tests/build_systems.rs:143 build_plans_match_the_contract_goldens", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (13 cases use this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:128 host_v0_capabilities_gate_unimplemented_sections" + ], + "windowsStatus": "validated-only", + "gapStage": "M4", + "gapDetail": "Windows 只做到清单验收与能力门控;输出文件模型解码(${outputFile}、JUnit 路径)没有任何宿主接线测试,属声明式构建接入。" + }, + { + "path": "build-command-stdout.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/build_systems.rs:128 build_fixtures_validate", + "rust/lithe-core/src/tests/build_systems.rs:143 build_plans_match_the_contract_goldens", + "rust/lithe-core/src/tests/build_systems.rs:1071 resolve_options_describe_explicit_empty_text_values", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (9 cases use this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:128 host_v0_capabilities_gate_unimplemented_sections" + ], + "windowsStatus": "validated-only", + "gapStage": "M4", + "gapDetail": "Windows 只有清单验收与能力门控;stdout 模型解码、选项解析与检测优先级无宿主接线测试,属声明式构建接入。" + }, + { + "path": "build-host-integration.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/build_systems.rs:128 build_fixtures_validate", + "rust/lithe-core/src/tests/build_systems.rs:143 build_plans_match_the_contract_goldens", + "rust/lithe-core/src/tests/build_systems.rs:482 maven_reactor_detects_through_the_host_integration", + "rust/lithe-core/src/tests/build_runtime_versions.rs:15 host_runtime_probe_arguments_are_portable_across_platforms", + "rust/lithe-core/src/tests/project_generators.rs:92 catalog_lists_generators_with_requirement_providers", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (5 cases use this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:128 host_v0_capabilities_gate_unimplemented_sections" + ], + "windowsStatus": "validated-only", + "gapStage": "M4", + "gapDetail": "Windows 只有清单验收与能力门控;hostIntegration 模型来源(Maven reactor 探测、运行时探测参数)无宿主接线测试,属声明式构建接入。" + }, + { + "path": "build-plans.json", + "category": "build-plans", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/build_systems.rs:143 build_plans_match_the_contract_goldens" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M4", + "gapDetail": "Windows 无直接读取;声明式构建接入(M4)时,宿主模型/任务/测试/诊断行为覆盖应以本 golden 对齐。" + }, + { + "path": "build-provider-language-server.plugin.json", + "category": "valid-manifest", + "outcome": "partial-support", + "coreEvidence": [ + "rust/lithe-core/src/tests/build_systems.rs:128 build_fixtures_validate", + "rust/lithe-core/src/tests/build_systems.rs:143 build_plans_match_the_contract_goldens", + "rust/lithe-core/src/tests/build_systems.rs:1460 launch_plans_name_exactly_one_process", + "rust/lithe-core/src/tests/declarative_plugins.rs:366 level_3_manifests_list_exactly_the_capabilities_they_use", + "rust/lithe-core/src/tests/declarative_plugins.rs:445 build_providers_require_their_own_host_capability", + "rust/lithe-core/src/tests/marketplace_plugins.rs:219 install_review_fixtures_match_golden_outcomes" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:80 invalid_manifest_fixtures_keep_their_stable_codes (only read indirectly as 4 case bases)" + ], + "windowsStatus": "unwired", + "gapStage": "M5", + "gapDetail": "不在 Windows 任何合法/门控清单中,仅作为 invalid-manifests 用例基底被间接读取;连'被能力门控拒绝'这一断言都没有专属覆盖。共享语言服务器 Provider 会话属 M5。" + }, + { + "path": "build-provider.plugin.json", + "category": "valid-manifest", + "outcome": "partial-support", + "coreEvidence": [ + "rust/lithe-core/src/tests/build_systems.rs:128 build_fixtures_validate", + "rust/lithe-core/src/tests/build_systems.rs:143 build_plans_match_the_contract_goldens", + "rust/lithe-core/src/tests/build_systems.rs:1115 provider_processes_are_install_review_programs", + "rust/lithe-core/src/tests/declarative_plugins.rs:445 build_providers_require_their_own_host_capability", + "rust/lithe-core/src/tests/marketplace_plugins.rs:219 install_review_fixtures_match_golden_outcomes", + "rust/lithe-core/src/tests/marketplace_plugins.rs:332 build_provider_versions_require_host_support_before_download" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:128 host_v0_capabilities_gate_unimplemented_sections" + ], + "windowsStatus": "validated-only", + "gapStage": "M5", + "gapDetail": "Windows 只断言该清单被能力门控拒绝;自有进程 Provider 的启动、litheBuild 协商、会话回收没有任何宿主测试,属 M5。" + }, + { + "path": "build-static-file.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/build_systems.rs:128 build_fixtures_validate", + "rust/lithe-core/src/tests/build_systems.rs:143 build_plans_match_the_contract_goldens", + "rust/lithe-core/src/tests/build_systems.rs:218 cargo_run_entries_match_the_builtin_detector", + "rust/lithe-core/src/tests/build_systems.rs:361 detection_applies_nesting_priority_and_rejections", + "rust/lithe-core/src/tests/build_systems.rs:1236 static_models_and_sync_fingerprints_reject_escaping_symlinks", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (33 cases use this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:128 host_v0_capabilities_gate_unimplemented_sections", + "windows/tauri/src-tauri/src/plugin_host/build_tests.rs detect_reads_the_static_model_and_describes_options" + ], + "windowsStatus": "validated-only", + "gapStage": "M4", + "gapDetail": "真实 Core 与宿主 registry 的静态模型、共享选项、指纹与撤销回归已覆盖此 fixture;生产 buildSystems 能力仍关闭,工具窗口及实际任务/测试执行尚未完整接通,不将内部夹具注入视为可安装产品验收。" + }, + { + "path": "build-tool-path.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/build_systems.rs:1599 explicit_build_tool_path_requires_supported_global_file_setting", + "rust/lithe-core/src/tests/build_systems.rs:1666 executable_candidates_order_and_constraints_are_shared_and_deterministic", + "rust/lithe-core/src/tests/build_systems.rs:1719 published_build_v2_paths_adapt_to_candidates_and_preserve_invalid_values", + "rust/lithe-core/src/tests/build_systems.rs:1755 executable_resolution_is_opt_in_and_custom_runtimes_share_the_same_plan" + ], + "windowsEvidence": [], + "windowsStatus": "unwired", + "gapStage": "M4", + "gapDetail": "Windows 侧零引用:不在合法/门控清单,也没有任何宿主测试读取;executableResolution:1 候选顺序与显式路径语义属 M4 接入。" + }, + { + "path": "command-plans.json", + "category": "command-plans", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:212 command_plan_fixtures_match_golden_expansions" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M5", + "gapDetail": "Windows 经 Core 命令消费展开结果;bundled_php_contract_package_expands_plans 实测了 runPlan/testPlan 展开但用的是 PHP 契约包而非本 golden。发现/测试/launchPlan 作用域的宿主行为测试属 M5。" + }, + { + "path": "debug-adapter-language-server.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:1314 debug_adapter_inside_the_language_server_has_no_process_of_its_own", + "rust/lithe-core/src/tests/declarative_plugins.rs:1356 debug_adapter_inside_the_language_server_is_a_reviewed_program", + "rust/lithe-core/src/tests/declarative_plugins.rs:1377 debug_adapter_inside_the_language_server_rejects_inconsistent_launches" + ], + "windowsEvidence": [], + "windowsStatus": "unwired", + "gapStage": "followup", + "gapDetail": "Core 已覆盖 tcpLanguageServer 声明、语言服务器启动计划与安装审查,但 Windows host_capabilities 未声明 debugAdapters;此夹具不在合法/能力门控清单,也没有对应 decoder 或宿主会话测试。复用语言服务器执行端点命令、连接调试端口及准确回收的产品链需在冻结路线图后的调试器接入阶段实现并验收;现有通用清单校验与内置 TCP 调试不等于本能力已接线。" + }, + { + "path": "debug-adapter-rejections.json", + "category": "invalid-edits", + "outcome": "rejection", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:1172 debug_adapter_rejection_fixtures_report_their_code_and_field" + ], + "windowsEvidence": [], + "windowsStatus": "unwired", + "gapStage": "followup", + "gapDetail": "preview #1510 新增 API 4 调试器声明拒绝金样。Windows 尚未接入 debugAdapters 宿主能力或此夹具的拒绝回归;保持已知缺口,不据 Core 单测宣称 Windows 支持。" + }, + { + "path": "debug-adapter-remote-attach.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:1512 debug_adapter_remote_attach_fixture_validates_and_keeps_its_bindings", + "rust/lithe-core/src/tests/declarative_plugins.rs:1537 debug_start_arguments_put_a_remote_endpoint_where_each_adapter_reads_it", + "rust/lithe-core/src/tests/declarative_plugins.rs:1574 debug_start_arguments_map_source_roots_in_the_shape_the_adapter_takes", + "rust/lithe-core/src/tests/declarative_plugins.rs:1650 debug_start_arguments_refuse_a_remote_target_without_a_usable_endpoint", + "rust/lithe-core/src/tests/declarative_plugins.rs:1796 debug_adapter_launch_plan_says_which_adapters_attach_to_a_remote_endpoint" + ], + "windowsEvidence": [], + "windowsStatus": "unwired", + "gapStage": "followup", + "gapDetail": "Core 已覆盖远端 attach 的 endpoint、targetBindings 和多种源码映射形状,Windows host_capabilities 仍未声明 debugAdapters;此夹具不在合法/能力门控清单,也没有读取它的宿主测试。远端目标到插件调试器参数的解码、注册与连接生命周期需在冻结路线图后的调试器接入阶段完成;既有通用 DAP/TCP 连接或非法清单循环不能证明该声明式远端接入已实现。" + }, + { + "path": "debug-adapter.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:1209 debug_adapter_fixture_is_accepted_with_normalized_requests" + ], + "windowsEvidence": [], + "windowsStatus": "unwired", + "gapStage": "followup", + "gapDetail": "preview #1510 新增 API 4/debugAdapters 1 声明。Windows 宿主仍未声明该能力,未接入此夹具的会话与运行链,需另行实现并验收后才能开放。" + }, + { + "path": "function-protocol.json", + "category": "function-protocol", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/lsp/interface/plugin_function.rs:558 the_offer_matches_the_fixture", + "rust/lithe-core/src/lsp/interface/plugin_function.rs:648 outcomes_follow_the_fixture", + "rust/lithe-core/src/lsp/interface/plugin_function.rs:703 host_requests_follow_the_fixture (protocol v2)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/functions/runtime.rs", + "windows/tauri/src-tauri/src/plugin_host/functions/web.rs", + "windows/tauri/src/features/plugins/plugin-web-function.test.ts" + ], + "windowsStatus": "indirect-core", + "gapStage": "M6", + "gapDetail": "R10/R11 设置与网页函数入口已复用 Core 协商、调用、取消和解码;受控 runtime/页面 listener 回归覆盖归属、审批、结果与清理。共享协议向量仍由 Core 消费;真实签名包、动态注册/撤回与进程树的完整 Windows 包验收保持 M6/M10 pending。" + }, + { + "path": "hello-lithe.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:65 contract_fixtures_and_official_plugins_validate", + "rust/lithe-core/src/tests/declarative_plugins.rs:532 ui_contributions_are_normalized_without_interpreting_text", + "rust/lithe-core/src/tests/declarative_plugins.rs:610 ui_icon_declarations_validate_and_preserve_theme_resources", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (12 cases use this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", + "windows/tauri/src-tauri/src/plugin_host/web.rs:1657 text_window_is_literal_and_hello_fixture_is_registered", + "windows/tauri/src/features/plugins/declarative-plugin-ui.test.ts:33 hello ui1 contributes a text window and all three command placements and revokes them" + ], + "windowsStatus": "wired", + "gapStage": null, + "gapDetail": null + }, + { + "path": "invalid-manifests.json", + "category": "invalid-edits", + "outcome": "rejection", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:80 invalid_manifest_fixtures_keep_their_stable_codes" + ], + "windowsStatus": "wired", + "gapStage": null, + "gapDetail": null + }, + { + "path": "language-host-integration.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:65 contract_fixtures_and_official_plugins_validate", + "rust/lithe-core/src/tests/declarative_plugins.rs:152 host_integration_capabilities_are_normalized_and_checked_against_the_integration", + "rust/lithe-core/src/tests/declarative_plugins.rs:276 runtime_launch_and_initialization_options_are_reviewed_programs", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (13 cases use this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities" + ], + "windowsStatus": "validated-only", + "gapStage": "M10", + "gapDetail": "Windows 只做清单验收;jdt 集成的启动计划解析、必需包资源校验与真实包接线没有 fixture 级宿主测试(launch.rs 测试使用 language-packaged-server),真实 JDT 包验收随 M10 第一阶段。" + }, + { + "path": "language-packaged-server.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:65 contract_fixtures_and_official_plugins_validate", + "rust/lithe-core/src/tests/declarative_plugins.rs:81 validation_normalizes_order_and_spelling_without_touching_arguments", + "rust/lithe-core/src/tests/declarative_plugins.rs:251 api_level_1_language_server_arguments_stay_literal", + "rust/lithe-core/src/tests/marketplace_plugins.rs:219 install_review_fixtures_match_golden_outcomes", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (23 cases use this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:162 scan_accepts_valid_packages_and_quarantines_broken_ones", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:196 store_problems_quarantine_packages_with_valid_manifests", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:258 catalog_build_combines_bundled_and_installed_sources", + "windows/tauri/src-tauri/src/plugin_host/install_tests.rs:43 directory_snapshot_copies_contents", + "windows/tauri/src-tauri/src/plugin_host/install_tests.rs:118 staging_install_replaces_version_and_rejects_broken_manifests", + "windows/tauri/src-tauri/src/plugin_host/install_tests.rs:185 rollback_picks_the_highest_previous_version", + "windows/tauri/src-tauri/src/plugin_host/launch.rs:933 resolve_launch_consumes_the_real_core_response_envelope" + ], + "windowsStatus": "wired", + "gapStage": null, + "gapDetail": null + }, + { + "path": "language-system-command.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:65 contract_fixtures_and_official_plugins_validate", + "rust/lithe-core/src/tests/declarative_plugins.rs:106 validation_keeps_the_leading_dot_of_hidden_file_names", + "rust/lithe-core/src/tests/declarative_plugins.rs:330 validation_probe_cannot_use_settings", + "rust/lithe-core/src/tests/marketplace_plugins.rs:219 install_review_fixtures_match_golden_outcomes", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (1 case uses this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:258 catalog_build_combines_bundled_and_installed_sources" + ], + "windowsStatus": "validated-only", + "gapStage": "M10", + "gapDetail": "Windows 覆盖清单验收与 bundled catalog 扫描,但 systemCommand 启动(PATH/工具发现解析、LITHE_*_PATH 导出)没有 fixture 级宿主测试;真实 systemCommand 插件(gopls/intelephense)验收随 M10 第一阶段。macOS 侧同样没有直接引用。" + }, + { + "path": "marketplace/index-cases.json", + "category": "marketplace", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/marketplace_plugins.rs:100 index_fixture_cases_match_expected_availability" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/marketplace_index.rs refresh_matches_the_shared_standard_answer" + ], + "windowsStatus": "indirect-core", + "gapStage": "M2", + "gapDetail": "Windows 网络模块已读取此 fixture,在本地受控 HTTP 与真实 Core 下校验能力门控标准答案及缓存;尚无生产市场 IPC/列表与安装消费者,保留 M2 产品接线缺口。" + }, + { + "path": "marketplace/index.json", + "category": "marketplace", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/marketplace_plugins.rs:100 index_fixture_cases_match_expected_availability", + "rust/lithe-core/src/tests/marketplace_plugins.rs:131 accepted_entries_are_normalized_newest_first", + "rust/lithe-core/src/tests/marketplace_plugins.rs:272 ui_api_availability_requires_an_implemented_platform_host", + "rust/lithe-core/src/tests/marketplace_plugins.rs:332 build_provider_versions_require_host_support_before_download" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/marketplace_index.rs refresh_matches_the_shared_standard_answer" + ], + "windowsStatus": "indirect-core", + "gapStage": "M2", + "gapDetail": "Windows 网络模块已读取此 fixture,在本地受控 HTTP 与真实 Core 下校验能力门控标准答案及缓存;尚无生产市场 IPC/列表与安装消费者,保留 M2 产品接线缺口。" + }, + { + "path": "marketplace/install-reviews.json", + "category": "marketplace", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/marketplace_plugins.rs:219 install_review_fixtures_match_golden_outcomes" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M2", + "gapDetail": "Windows 磁盘安装链走 installReview 命令但测试用 language-packaged-server 清单而非本 golden 审查矩阵;完整来源/审批记录接入属 M2。" + }, + { + "path": "marketplace/invalid-index-entries.json", + "category": "marketplace", + "outcome": "rejection", + "coreEvidence": [ + "rust/lithe-core/src/tests/marketplace_plugins.rs:147 invalid_index_entries_report_their_stable_codes" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M2", + "gapDetail": "Windows 无市场索引消费路径(M2);拒绝码一致性的宿主断言未建立。" + }, + { + "path": "marketplace/source-urls.json", + "category": "marketplace", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/marketplace_plugins.rs:203 source_url_fixtures_resolve_to_expected_index_urls" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M2", + "gapDetail": "Windows 无源解析入口(M2)。" + }, + { + "path": "native-controls.plugin.json", + "category": "native-ui", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:827 native_public_controls_validate_types_ranges_info_and_buttons" + ], + "windowsEvidence": [ + "windows/tauri/src/features/plugins/native-ui/native-panel.test.tsx the real native-controls form renders shared controls and IME Enter commits all current fields once" + ], + "windowsStatus": "wired", + "notes": "真实共享 fixture 已经进入 Windows 生产 renderer 的受控组件回归;ui 3 准入、完整函数和真实 Tauri/主题/布局视觉验收仍 pending。" + }, + { + "path": "native-form.plugin.json", + "category": "native-ui", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:721 native_ui_forms_validate_controls_values_actions_and_budgets", + "rust/lithe-core/src/tests/declarative_plugins.rs:785 native_ui_form_grid_validates_columns_spans_and_scope" + ], + "windowsEvidence": [ + "windows/tauri/src/features/plugins/native-ui/native-panel.test.tsx the real native-form fixture restores a local draft after hide and reset restores its declared value" + ], + "windowsStatus": "wired", + "notes": "真实共享 fixture 已经进入 Windows 生产 renderer 的受控组件回归;ui 3 准入、完整函数和真实 Tauri/主题/布局视觉验收仍 pending。" + }, + { + "path": "native-panel.plugin.json", + "category": "native-ui", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:666 native_ui_validates_version_ownership_actions_and_budgets", + "rust/lithe-core/src/tests/declarative_plugins.rs:785 native_ui_form_grid_validates_columns_spans_and_scope" + ], + "windowsEvidence": [ + "windows/tauri/src/features/plugins/native-ui/native-panel.test.tsx the real native tree fixture virtualizes long results and keeps keyboard selection on expanded children" + ], + "windowsStatus": "wired", + "notes": "真实共享 fixture 已经进入 Windows 生产 renderer 的受控组件回归;ui 3 准入、完整函数和真实 Tauri/主题/布局视觉验收仍 pending。" + }, + { + "path": "node-runtime.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:65 contract_fixtures_and_official_plugins_validate", + "rust/lithe-core/src/tests/declarative_plugins.rs:276 runtime_launch_and_initialization_options_are_reviewed_programs", + "rust/lithe-core/src/tests/declarative_plugins.rs:351 settings_are_normalized_in_identifier_order", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (24 cases use this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities" + ], + "windowsStatus": "validated-only", + "gapStage": "M10", + "gapDetail": "Windows 只做清单验收;runtime 启动(packagedRuntime/minimumVersion 探测)与设置解析的 fixture 级宿主测试缺失(PHP 契约包端到端覆盖了 runtime node 计划展开,但不是本清单),真实插件验收随 M10 第一阶段。" + }, + { + "path": "project-generator-templates/.gitattributes", + "category": "template-assets", + "outcome": "success", + "coreEvidence": [ + "shared/contracts/plugins/v1/fixtures/project-generator-templates/.gitattributes:1 * -text preserves template bytes in Git; not a generated project file" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M8", + "gapDetail": "Git 模板字节策略(* -text),不属于 files 步骤生成产物;Windows 未单独验证跨检出模板字节稳定性,保留 M8/M10 验收缺口。" + }, + { + "path": "project-generator-templates/app/README.md", + "category": "template-assets", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/project_generators.rs:65 golden_project_plans_match (plan.rs collect_files walks the templates directory)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/project_generators/tests.rs" + ], + "windowsStatus": "indirect-core", + "gapStage": "M8", + "gapDetail": "Windows 宿主测试通过 fixture 默认 app 计划调用真实 Core 并生成/检查 README.md、.gitignore;模板逐字节 golden 仍由 Core 拥有。projectGenerators 门控关闭,真实安装包向导及创建后构建闭环待 M8/M10。" + }, + { + "path": "project-generator-templates/app/dot-gitignore", + "category": "template-assets", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/project_generators.rs:65 golden_project_plans_match (plan.rs collect_files walks the templates directory)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/project_generators/tests.rs" + ], + "windowsStatus": "indirect-core", + "gapStage": "M8", + "gapDetail": "Windows 宿主测试通过 fixture 默认 app 计划调用真实 Core 并生成/检查 README.md、.gitignore;模板逐字节 golden 仍由 Core 拥有。projectGenerators 门控关闭,真实安装包向导及创建后构建闭环待 M8/M10。" + }, + { + "path": "project-generator-templates/main/Main.java", + "category": "template-assets", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/project_generators.rs:65 golden_project_plans_match (plan.rs collect_files walks the templates directory)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/project_generators/tests.rs" + ], + "windowsStatus": "indirect-core", + "gapStage": "M8", + "gapDetail": "Windows R10 新增并执行 maven + sample=true 的真实 Core 计划及事务生成回归,检查 Main.java 路径/内容与 collect 结果;完整正式包向导和创建后构建仍待 M8/M10。" + }, + { + "path": "project-generator.plugin.json", + "category": "project-generation", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/project_generators.rs:65 golden_project_plans_match", + "rust/lithe-core/src/tests/project_generators.rs:92 catalog_lists_generators_with_requirement_providers", + "rust/lithe-core/src/tests/project_generators.rs:259 rejects_invalid_generator_declarations", + "rust/lithe-core/src/tests/project_generators.rs:327 project_generators_need_api_level_3", + "rust/lithe-core/src/tests/project_generators.rs:339 generator_commands_require_install_review_again" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/project_generators/tests.rs", + "windows/tauri/src-tauri/src/plugin_host/project_generators/archive.rs", + "windows/tauri/src/features/project-generators/generation-session.test.ts" + ], + "windowsStatus": "indirect-core", + "gapStage": "M8", + "gapDetail": "Windows R10 已接入显式启用、市场查询和 segmented 字段,使用真实 fixture 验证默认及 Maven/sample 生成分支、归档和事务清理。能力仍关闭,完整正式插件包/实机向导及创建后构建验收待 M8/M10。" + }, + { + "path": "project-plans.json", + "category": "project-plans", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/project_generators.rs:65 golden_project_plans_match" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/project_generators/tests.rs" + ], + "windowsStatus": "indirect-core", + "gapStage": "M8", + "gapDetail": "Windows 宿主回归调用与此 golden 相同的 Core 计划命令并执行默认 app 产物,但未读取/逐 case 比对此 JSON;完整 golden 仍由 Core 测试消费。其余生成分支与真实安装包闭环待 M8/M10。" + }, + { + "path": "runtime-versions.json", + "category": "runtime-versions", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:312 runtime_version_fixtures_match_golden_outcomes" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M10", + "gapDetail": "Windows launch.rs 用 node/java/go 真实输出格式自测版本解析,未读取本 golden;建议接入宿主探测路径时用本 fixture 对齐,真实运行时验收随 M10。" + }, + { + "path": "settings-v2.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:901 immediate_settings_must_be_used_by_workspace_configuration", + "rust/lithe-core/src/tests/declarative_plugins.rs:911 settings_v2_manifests_validate_and_resolve_by_layer", + "rust/lithe-core/src/tests/declarative_plugins.rs:958 settings_v2_rejects_unsafe_setting_use" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:128 host_v0_capabilities_gate_unimplemented_sections", + "windows/tauri/src-tauri/src/plugin_host/settings/snapshot/tests.rs", + "windows/tauri/src/features/plugins/declarative-settings-session.test.ts", + "windows/tauri/src/features/plugins/declarative-plugin-reload.test.ts", + "windows/tauri/src-tauri/src/plugin_host/functions/tests.rs", + "windows/tauri/src-tauri/src/plugin_host/functions/runtime/tests.rs", + "windows/tauri/src/features/plugins/plugin-function-session.test.ts" + ], + "windowsStatus": "indirect-core", + "gapStage": "M6", + "gapDetail": "真实 fixture 用于 scope/value、action/page 及共享语言 Provider 拒绝校验;分层存储、CAS、global reload 及独立 Provider 设置动作另有状态/存储/会话回归。此 fixture 的 action 使用共享语言服务,当前仍明确 unavailable;buildSystems/buildProviders 门控、project reload 和完整包验收待 M6/M10。" + }, + { + "path": "signature-vectors.json", + "category": "signature-vectors", + "outcome": "success", + "coreEvidence": [], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/signature.rs shared_fixture_verifies_end_to_end" + ], + "windowsStatus": "validated-only", + "gapStage": "M1", + "gapDetail": "共享公开向量已被 Rust 签名验证测试消费;Swift 向量测试等待 macOS 执行,签名包的完整安装审批、失败恢复及回滚生产链仍由 M1 修复任务验证。" + }, + { + "path": "vcs-provider.plugin.json", + "category": "vcs-provider", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/vcs.rs:65 the_provider_fixture_validates_and_is_reviewed_with_its_tool", + "rust/lithe-core/src/tests/vcs.rs:94 provider_manifests_state_permissions_activation_and_requirements", + "rust/lithe-core/src/tests/vcs.rs:147 a_launch_plan_names_the_vcs_provider_it_starts", + "rust/lithe-core/src/tests/vcs.rs:196 root_detection_matches_the_contract_cases" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/vcs_tests.rs", + "windows/tauri/src-tauri/src/plugin_host/vcs_status_tests.rs", + "windows/tauri/src-tauri/src/plugin_host/vcs_session_tests.rs", + "windows/tauri/src-tauri/src/plugin_host/vcs_query_tests.rs", + "windows/tauri/src/features/vcs/vcs-history-panel.test.tsx" + ], + "windowsStatus": "indirect-core", + "gapStage": "M9", + "gapDetail": "R9-R11 使用真实 fixture 验证根检测、Core 归属/解码及只读 status/history/fileHistory/baseContent 宿主投影;短会话与监听映射、分页、原 owner 取消重试、缺失/重命名/二进制及 junction 路径边界有回归。vcsProviders 门控仍关闭;真实安装 Provider/客户端/进程树闭环与后续写操作待 M9/M10。" + }, + { + "path": "web-panel.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:65 contract_fixtures_and_official_plugins_validate", + "rust/lithe-core/src/tests/declarative_plugins.rs:563 web_tool_windows_need_ui_2_their_permission_and_a_package_page", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (8 cases use this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities" + ], + "windowsStatus": "validated-only", + "gapStage": "M6", + "gapDetail": "Windows 宿主已声明 ui:[1,2] 并具备 Web 工具窗口、build.* 及 R11 functions.* 的机制接线和受控回归;本 fixture 的完整签名包页面/入口、真实 Provider 和进程树仍待 M6/M10 实机验收。" + } + ] +} diff --git a/scripts/plugin-host-acceptance/acceptance_driver.py b/scripts/plugin-host-acceptance/acceptance_driver.py new file mode 100644 index 0000000000..b15cfb2c53 --- /dev/null +++ b/scripts/plugin-host-acceptance/acceptance_driver.py @@ -0,0 +1,1642 @@ +#!/usr/bin/env python3 +"""Drive the real Windows Tauri/WebView2 declarative-plugin workflow.""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import ctypes +import hashlib +import json +import os +import re +import shutil +import socket +import subprocess +import sys +import time +import urllib.request +from ctypes import wintypes +from pathlib import Path + +import psutil +from playwright.sync_api import Page, Playwright, expect, sync_playwright +from playwright._impl._errors import TargetClosedError + + +PLUGIN_ID = "io.github.lithe-acceptance.host" +DISPLAY_NAME = "Lithe Host Acceptance" +ACCEPTANCE_IDENTIFIER = "app.lithe.windows.host-acceptance" +CREATE_NEW_PROCESS_GROUP = 0x00000200 +CREATE_NO_WINDOW = 0x08000000 +PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +PROCESS_TERMINATE = 0x0001 +SYNCHRONIZE = 0x00100000 +WAIT_TIMEOUT = 258 +TH32CS_SNAPPROCESS = 0x00000002 +ERROR_NO_MORE_FILES = 18 +INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value + + +class ProcessEntry32(ctypes.Structure): + _fields_ = [ + ("dwSize", wintypes.DWORD), ("cntUsage", wintypes.DWORD), + ("th32ProcessID", wintypes.DWORD), ("th32DefaultHeapID", ctypes.c_size_t), + ("th32ModuleID", wintypes.DWORD), ("cntThreads", wintypes.DWORD), + ("th32ParentProcessID", wintypes.DWORD), ("pcPriClassBase", wintypes.LONG), + ("dwFlags", wintypes.DWORD), ("szExeFile", wintypes.WCHAR * 260), + ] + + +kernel32 = ctypes.windll.kernel32 +kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD] +kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE +kernel32.Process32FirstW.argtypes = [wintypes.HANDLE, ctypes.POINTER(ProcessEntry32)] +kernel32.Process32FirstW.restype = wintypes.BOOL +kernel32.Process32NextW.argtypes = [wintypes.HANDLE, ctypes.POINTER(ProcessEntry32)] +kernel32.Process32NextW.restype = wintypes.BOOL +kernel32.GetLastError.argtypes = [] +kernel32.GetLastError.restype = wintypes.DWORD +kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] +kernel32.OpenProcess.restype = wintypes.HANDLE +kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] +kernel32.GetExitCodeProcess.restype = wintypes.BOOL +kernel32.CloseHandle.argtypes = [wintypes.HANDLE] +kernel32.CloseHandle.restype = wintypes.BOOL +kernel32.GetProcessTimes.argtypes = [wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4 +kernel32.GetProcessTimes.restype = wintypes.BOOL +kernel32.QueryFullProcessImageNameW.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD)] +kernel32.QueryFullProcessImageNameW.restype = wintypes.BOOL +kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] +kernel32.WaitForSingleObject.restype = wintypes.DWORD +kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT] +kernel32.TerminateProcess.restype = wintypes.BOOL + + +class OwnedProcess: + """A retained handle, rather than a reusable PID, authorizes termination.""" + + def __init__(self, pid: int, handle, creation_time: int, executable: str): + self.pid = pid + self.handle = handle + self.creation_time = creation_time + self.executable = executable + # Keep kernel-object ownership from earlier successful inspections even + # if a later query fails or the root exits before cleanup. + self.descendants: list[OwnedProcess] = [] + + @classmethod + def capture(cls, pid: int, expected: dict | None = None): + handle = kernel32.OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_TERMINATE | SYNCHRONIZE, False, pid + ) + if not handle: + return None + try: + created, exited, kernel, user = (wintypes.FILETIME() for _ in range(4)) + if not kernel32.GetProcessTimes(handle, ctypes.byref(created), ctypes.byref(exited), ctypes.byref(kernel), ctypes.byref(user)): + return None + creation_time = (created.dwHighDateTime << 32) | created.dwLowDateTime + image = ctypes.create_unicode_buffer(32768) + length = wintypes.DWORD(len(image)) + if not kernel32.QueryFullProcessImageNameW(handle, 0, image, ctypes.byref(length)): + return None + if expected is not None: + # Both sides retain native FILETIME ticks, without float loss. + if creation_time != int(expected.get("CreationTime", -1)): + return None + expected_image = expected.get("ExecutablePath") or "" + if os.path.normcase(image.value) != os.path.normcase(expected_image): + # An inspector can preserve a runtime-manager junction + # while the handle reports the executable's physical path. + try: + if not os.path.samefile(image.value, expected_image): + return None + except OSError: + return None + result = cls(pid, handle, creation_time, image.value) + handle = None + return result + finally: + if handle: + kernel32.CloseHandle(handle) + + def running(self) -> bool: + if not self.handle: + return False + state = kernel32.WaitForSingleObject(self.handle, 0) + if state not in (0, WAIT_TIMEOUT): + raise ctypes.WinError(kernel32.GetLastError()) + return state == WAIT_TIMEOUT + + def terminate(self) -> None: + if self.running() and not kernel32.TerminateProcess(self.handle, 1): + if self.running(): + raise RuntimeError(f"could not terminate the owned process handle for {self.pid}") + + def close(self) -> None: + if self.handle: + kernel32.CloseHandle(self.handle) + self.handle = None + + +owned_processes: dict[int, OwnedProcess] = {} +unverified_fixture_pids: set[int] = set() +fixture_processes: dict[int, OwnedProcess] = {} + + +def remember_owned_process(pid: int, expected: dict | None = None) -> OwnedProcess | None: + previous = owned_processes.get(pid) + if previous is not None and previous.running(): + if expected is None or previous.creation_time == int(expected.get("CreationTime", -1)): + return previous + return None + process = OwnedProcess.capture(pid, expected) + if process is not None: + if previous is not None: + previous.close() + owned_processes[pid] = process + return process + + +def owned_process_running(pid: int, expected: dict | None = None) -> bool: + process = owned_processes.get(pid) + return process is not None and process.running() and ( + expected is None or process.creation_time == int(expected.get("CreationTime", -1)) + ) + + +def process_exists(pid: int) -> bool: + if pid <= 0: + return False + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return False + exit_code = wintypes.DWORD() + try: + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)): + return False + return exit_code.value == 259 # STILL_ACTIVE + finally: + kernel32.CloseHandle(handle) + + +def terminate_process_tree(pid: int, timeout_seconds: float = 10, inspect_descendants: bool = True) -> None: + owner = owned_processes.get(pid) + if owner is None: + if process_exists(pid): + raise RuntimeError(f"refusing to terminate PID {pid} without a retained ownership handle") + return + inspection_error = None + if inspect_descendants and owner.running(): + try: + capture_owned_tree(owner) + except Exception as error: # noqa: BLE001 - cleanup must still use retained handles. + inspection_error = error + targets = list(dict.fromkeys([owner] + (owner.descendants if inspect_descendants else []))) + # Each termination targets the original kernel object, including if it + # exited between inspection and this call. Never reopen a historical PID. + termination_errors = [] + for process in targets: + try: + process.terminate() + except Exception as error: # noqa: BLE001 - another owned handle still needs cleanup. + termination_errors.append((process, str(error))) + deadline = time.monotonic() + timeout_seconds + while any(process.running() for process in targets) and time.monotonic() < deadline: + time.sleep(0.05) + surviving_handles = [process for process in targets if process.running()] + survivors = [process.pid for process in surviving_handles] + # Windows can deny TerminateProcess while a console/process is already + # exiting. The retained object becoming signaled within this same bounded + # cleanup is proof that it exited; preserve errors only for survivors. + cleanup_errors = [message for process, message in termination_errors if process in surviving_handles] + if survivors: + cleanup_errors.append(f"owned process handles survived cleanup: {survivors}") + if inspection_error is not None: + suffix = f"; {'; '.join(cleanup_errors)}" if cleanup_errors else "" + raise RuntimeError( + f"could not inspect the owned process tree rooted at {pid}: {inspection_error}; " + f"retained-handle cleanup attempted, uninspected descendants may remain{suffix}" + ) from inspection_error + if cleanup_errors: + raise RuntimeError("; ".join(cleanup_errors)) + + +def run_bounded_process(command: list[str], timeout_seconds: float, *, inspect_descendants: bool = True) -> subprocess.CompletedProcess: + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + creationflags=CREATE_NEW_PROCESS_GROUP, + ) + owner = remember_owned_process(process.pid) + try: + stdout, stderr = process.communicate(timeout=timeout_seconds) + return subprocess.CompletedProcess(command, process.returncode, stdout, stderr) + except subprocess.TimeoutExpired as error: + cleanup_error = None + try: + terminate_process_tree(process.pid, inspect_descendants=inspect_descendants) + except Exception as failure: # noqa: BLE001 - preserve cleanup failure in the timeout. + cleanup_error = failure + # Popen itself retains a kernel handle, including if capturing our + # stronger identity failed. Do not release that last cleanup owner early. + if process.poll() is None: + try: + process.kill() + process.wait(timeout=2) + except Exception as failure: # noqa: BLE001 - retain both cleanup diagnostics. + cleanup_error = RuntimeError(f"{cleanup_error or 'owned cleanup failed'}; Popen cleanup: {failure}") + with contextlib.suppress(Exception): + process.communicate(timeout=2) + suffix = f"; cleanup could not be confirmed: {cleanup_error}" if cleanup_error else "" + raise TimeoutError( + f"process exceeded its {timeout_seconds:.1f}s local deadline: {command[0]}{suffix}" + ) from (cleanup_error or error) + finally: + if owner is not None: + owner.close() + if owned_processes.get(process.pid) is owner: + del owned_processes[process.pid] + + +def wait_until(predicate, timeout_seconds: float, description: str, interval: float = 0.1): + deadline = time.monotonic() + timeout_seconds + last_error = None + while time.monotonic() < deadline: + try: + value = predicate() + if value: + return value + except Exception as error: # noqa: BLE001 - preserve the latest polling diagnostic. + last_error = error + time.sleep(interval) + suffix = f"; last error: {last_error}" if last_error else "" + raise TimeoutError(f"timed out waiting for {description}{suffix}") + + +def hash_tree(root: Path) -> dict[str, str]: + result = {} + for path in sorted(candidate for candidate in root.rglob("*") if candidate.is_file()): + relative = path.relative_to(root).as_posix() + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + result[relative] = digest.hexdigest() + return result + + +def write_json(path: Path, value) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def process_tree_ids(root_pid: int) -> list[int]: + """Read only topology; these PIDs never authorize termination by themselves.""" + snapshot = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) + if snapshot == INVALID_HANDLE_VALUE: + raise ctypes.WinError(kernel32.GetLastError()) + try: + entry = ProcessEntry32() + entry.dwSize = ctypes.sizeof(entry) + parents: dict[int, list[int]] = {} + present = kernel32.Process32FirstW(snapshot, ctypes.byref(entry)) + while present: + parents.setdefault(entry.th32ParentProcessID, []).append(entry.th32ProcessID) + present = kernel32.Process32NextW(snapshot, ctypes.byref(entry)) + error = kernel32.GetLastError() + if error != ERROR_NO_MORE_FILES: + raise ctypes.WinError(error) + finally: + kernel32.CloseHandle(snapshot) + pending, seen, result = [root_pid], set(), [] + while pending: + pid = pending.pop() + if pid in seen: + continue + seen.add(pid) + result.append(pid) + pending.extend(parents.get(pid, [])) + return result + + +def process_exit_proven(pid: int) -> bool: + """A signaled kernel object or ERROR_INVALID_PARAMETER proves exit. + + Access denied and failed waits are inspection failures, never absence. + This query-only handle does not authorize termination or adopt a reused PID. + """ + handle = kernel32.OpenProcess(SYNCHRONIZE, False, pid) + if not handle: + error = kernel32.GetLastError() + if error == 87: # ERROR_INVALID_PARAMETER: PID no longer exists. + return True + raise ctypes.WinError(error) + try: + state = kernel32.WaitForSingleObject(handle, 0) + if state == 0: # WAIT_OBJECT_0, including exit status STILL_ACTIVE (259). + return True + if state == WAIT_TIMEOUT: + return False + raise ctypes.WinError(kernel32.GetLastError()) + finally: + kernel32.CloseHandle(handle) + + +def process_record(pid: int) -> dict | None: + """Use upstream native inspection, retaining exact Win32 identity ticks. + + psutil handles Windows command-line/parent inspection without WMI. Its + PID-reuse checks supplement, rather than replace, the retained kernel + handles used to authorize cleanup. Do not round FILETIME through a float. + """ + identity = None + try: + process = psutil.Process(pid) + process.create_time() # Freeze psutil's original process identity. + identity = OwnedProcess.capture(pid) + if identity is None: + if not process.is_running() or process_exit_proven(pid): + return None + raise RuntimeError(f"could not inspect the process identity for {pid}") + record = { + "ProcessId": pid, + "ParentProcessId": process.ppid(), + "Name": process.name(), + "ExecutablePath": identity.executable, + "CommandLine": subprocess.list2cmdline(process.cmdline()), + "CreationTime": identity.creation_time, + } + if not process.is_running() or not identity.running(): + return None + return record + except psutil.NoSuchProcess: + return None + except psutil.AccessDenied: + # psutil can query after the short-lived process has exited. Recheck + # with a non-terminating handle; a live/uninspectable owner still fails. + if (identity is not None and not identity.running()) or process_exit_proven(pid): + return None + raise + finally: + if identity is not None: + identity.close() + + +def process_tree(root_pid: int) -> list[dict]: + return [record for pid in process_tree_ids(root_pid) + if (record := process_record(pid)) is not None] + + +def acceptance_process_inventory(executable: Path, identifier: str) -> list[dict]: + """Wrapper fallback: inspect only matching app/fixture candidates. + + Returned records are evidence, never termination authority. The wrapper + reopens and retains handles, then compares exact times and executable paths. + """ + expected = os.path.normcase(str(executable.resolve())) + state_root = Path(os.environ["LOCALAPPDATA"]) / identifier / "plugin-state" / PLUGIN_ID + roots = set() + for process in psutil.process_iter(["pid", "name"]): + name = (process.info["name"] or "").lower() + if name not in (executable.name.lower(), "node.exe", "node"): + continue + record = process_record(process.pid) + if record is None: + continue + if (os.path.normcase(record["ExecutablePath"]) == expected + or fixture_command_matches(record["CommandLine"], state_root)): + roots.add(process.pid) + records = {} + for root in roots: + for record in process_tree(root): + records[record["ProcessId"]] = record + return list(records.values()) + + +def capture_owned_tree(root: OwnedProcess, records: list[dict] | None = None) -> list[OwnedProcess]: + if not root.running(): + return [] + records = process_tree(root.pid) if records is None else records + root_record = next((item for item in records if int(item["ProcessId"]) == root.pid), None) + if root_record is None or int(root_record.get("CreationTime", -1)) != root.creation_time: + return [] + pending = {root.pid: root} + accepted = [] + while pending: + parent_pid, parent = pending.popitem() + for record in records: + pid = int(record["ProcessId"]) + if int(record["ParentProcessId"]) != parent_pid or pid == root.pid: + continue + if int(record.get("CreationTime", -1)) < parent.creation_time: + continue + child = remember_owned_process(pid, record) + if child is None: + if process_exit_proven(pid): + continue + raise RuntimeError(f"could not retain the live process identity for {pid}") + if child is not None and child not in accepted: + accepted.append(child) + if child not in root.descendants: + root.descendants.append(child) + pending[pid] = child + return accepted + + +class Reporter: + def __init__(self, progress_path: Path | None = None): + self.tests: list[dict] = [] + self.progress_path = progress_path + self.capture_failure = lambda: None + + @contextlib.contextmanager + def step(self, name: str, max_ms: int = 120_000): + started = time.monotonic() + details = "" + status = "passed" + print(f"START {name}", flush=True) + try: + yield + except Exception as error: # noqa: BLE001 - report and re-raise the exact acceptance failure. + status = "failed" + details = f"{type(error).__name__}: {error}" + with contextlib.suppress(Exception): + self.capture_failure() + raise + finally: + self.tests.append( + { + "name": name, + "suite": "plugin-host-acceptance / native-windows-x64", + "status": status, + "durationMs": round((time.monotonic() - started) * 1000, 3), + "details": details, + "maxMs": max_ms, + } + ) + print(f"{status.upper()} {name} ({self.tests[-1]['durationMs']:.0f} ms)", flush=True) + if self.progress_path is not None: + write_json(self.progress_path, {"tests": self.tests}) + + def document_pending( + self, name: str, details: str, suite: str = "plugin-host-acceptance / pending-platforms" + ): + self.tests.append( + { + "name": name, + "suite": suite, + "status": "skipped", + "durationMs": 0, + "details": details, + "maxMs": 1, + } + ) + + +def safe_remove_owned_directory(path: Path, expected_parent: Path, expected_name: str) -> None: + # MSIX redirects an existing AppData leaf to LocalCache; resolving its + # parent separately gives a different physical root. Prove the exact + # caller-owned logical path, then let Windows perform its redirection. + resolved = Path(os.path.abspath(path)) + if resolved.name != expected_name or resolved.parent != Path(os.path.abspath(expected_parent)): + raise RuntimeError(f"refusing to remove non-acceptance directory: {resolved}") + if resolved.is_symlink() or resolved.is_junction(): + raise RuntimeError(f"refusing to remove a linked acceptance directory: {resolved}") + deadline = time.monotonic() + 5 + while resolved.exists(): + try: + shutil.rmtree(resolved) + except OSError: + if time.monotonic() >= deadline: + raise + time.sleep(0.05) + + +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + return listener.getsockname()[1] + + +def cdp_ready(port: int) -> bool: + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=0.5) as response: + return response.status == 200 + except Exception: + return False + + +def invoke(page: Page, command: str, arguments: dict | None = None): + return page.evaluate( + """async ([command, args]) => { + if (!window.__TAURI_INTERNALS__?.invoke) throw new Error("Tauri IPC is unavailable"); + let deadline; + try { + return await Promise.race([ + window.__TAURI_INTERNALS__.invoke(command, args ?? {}), + new Promise((_, reject) => { + deadline = setTimeout(() => reject(new Error(`IPC deadline exceeded: ${command}`)), 10000); + }), + ]); + } finally { + clearTimeout(deadline); + } + }""", + [command, arguments or {}], + ) + + +def disconnect_cdp_observer(browser) -> None: + # For connect_over_cdp, Playwright 1.60 closes its transport, not the + # external browser. Its public close() has no timeout; use the same pinned + # sync bridge with an explicit bound around that transport-only operation. + browser._sync(asyncio.wait_for(browser._impl_obj.close(reason="native acceptance observer detached"), timeout=5)) + if browser.is_connected(): + raise RuntimeError("the acceptance CDP observer remained connected") + + +def application_window_inventory(owner: OwnedProcess) -> list[dict]: + if not owner.running(): + return [] + windows = [] + + @EnumWindowsProc + def callback(hwnd, _): + if window_pid(hwnd) == owner.pid: + title = ctypes.create_unicode_buffer(1024) + user32.GetWindowTextW(hwnd, title, len(title)) + windows.append({"hwnd": int(hwnd), "class": window_class(hwnd), "title": title.value, + "visible": bool(user32.IsWindowVisible(hwnd))}) + return True + + if not user32.EnumWindows(callback, 0): + raise RuntimeError("could not inventory the owned application's top-level windows") + return windows + + +def request_application_quit(page: Page, evidence: dict | None = None) -> dict: + # Match requestApplicationQuit() exactly: getAllWebviewWindows, then emitTo + # every label, including hidden product windows. Native auxiliary HWNDs do + # not represent application windows and must never receive synthetic close. + # The bundled @tauri-apps/api 2.11.1 webviewWindow.js uses this window-list + # command; webview|get_all_webviews belongs to the separate getAllWebviews. + labels = invoke(page, "plugin:window|get_all_windows") + if not isinstance(labels, list) or not labels or any(not isinstance(label, str) or not label for label in labels): + raise RuntimeError("the product did not return its WebviewWindow labels for quit") + evidence = evidence if evidence is not None else {} + evidence.update({"windowLabels": labels, "observedProductPageClosed": False, "dispatch": "started"}) + with page.expect_event("close", timeout=15_000): + try: + page.evaluate("""async (labels) => { + let deadline; + try { + await Promise.race([ + Promise.all(labels.map(label => window.__TAURI_INTERNALS__.invoke("plugin:event|emit_to", { + target: { kind: "AnyLabel", label }, event: "menu_quit_app", payload: null, + }))), + new Promise((_, reject) => { + deadline = setTimeout(() => reject(new Error("Application quit dispatch deadline exceeded")), 10000); + }), + ]); + } finally { clearTimeout(deadline); } + }""", labels) + evidence["dispatch"] = "acknowledged" + except TargetClosedError: + # Closing the actual product page can win the acknowledgement + # race. The enclosing event wait still requires that real close. + evidence["dispatch"] = "target-closed-before-acknowledgement" + evidence["observedProductPageClosed"] = True + return evidence + + +class AppSession: + def __init__( + self, + playwright: Playwright, + executable: Path, + workspace: Path, + webview_data: Path, + log_root: Path, + sequence: int, + ): + self.playwright = playwright + self.executable = executable + self.workspace = workspace + self.webview_data = webview_data + self.log_root = log_root + self.sequence = sequence + self.process: subprocess.Popen | None = None + self.browser = None + self.page: Page | None = None + self.stdout_file = None + self.stderr_file = None + self.initial_tree: list[dict] = [] + self.root_owner: OwnedProcess | None = None + self.owned_tree: list[OwnedProcess] = [] + self.stop_attempts: list[dict] = [] + + def start(self) -> Page: + port = free_port() + environment = os.environ.copy() + environment["WEBVIEW2_USER_DATA_FOLDER"] = str(self.webview_data) + environment["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"] = ( + f"--remote-debugging-port={port} --remote-allow-origins=http://127.0.0.1:{port}" + ) + self.log_root.mkdir(parents=True, exist_ok=True) + self.stdout_file = (self.log_root / f"app-{self.sequence}.stdout.log").open("w", encoding="utf-8") + self.stderr_file = (self.log_root / f"app-{self.sequence}.stderr.log").open("w", encoding="utf-8") + self.process = subprocess.Popen( + [str(self.executable), str(self.workspace)], + cwd=self.executable.parent, + env=environment, + stdout=self.stdout_file, + stderr=self.stderr_file, + creationflags=CREATE_NEW_PROCESS_GROUP, + ) + self.root_owner = remember_owned_process(self.process.pid) + if self.root_owner is None or Path(self.root_owner.executable).resolve() != self.executable.resolve(): + raise RuntimeError("could not retain the isolated application's process identity") + try: + wait_until(lambda: cdp_ready(port), 30, "the isolated WebView2 CDP endpoint") + self.browser = self.playwright.chromium.connect_over_cdp( + f"http://127.0.0.1:{port}", timeout=15_000 + ) + self.page = wait_until(self._tauri_page, 30, "the real Tauri product page") + self.page.wait_for_load_state("domcontentloaded", timeout=15_000) + self.page.wait_for_function("() => Boolean(window.__TAURI_INTERNALS__?.invoke)", timeout=15_000) + runtime_data = Path(invoke(self.page, "plugin:path|resolve_directory", {"directory": 15})) + expected_data = Path(os.environ["LOCALAPPDATA"]) / ACCEPTANCE_IDENTIFIER + if runtime_data.resolve() != expected_data.resolve(): + raise RuntimeError(f"the executable has an unexpected application data root: {runtime_data}") + write_json(self.log_root / f"app-{self.sequence}.identity.json", { + "appLocalData": str(runtime_data), + "expectedAppLocalData": str(expected_data), + }) + self.initial_tree = process_tree(self.process.pid) + self.owned_tree = capture_owned_tree(self.root_owner, self.initial_tree) + return self.page + except Exception: + with contextlib.suppress(Exception): + terminate_process_tree(self.process.pid) + raise + + def _tauri_page(self): + if self.browser is None: + return None + for context in self.browser.contexts: + for candidate in context.pages: + try: + if candidate.evaluate("() => Boolean(window.__TAURI_INTERNALS__?.invoke)"): + return candidate + except Exception: + continue + return None + + def stop(self, require_graceful: bool = True) -> dict: + if self.process is None: + return {"forced": False, "tree": []} + current_tree = process_tree(self.process.pid) if self.root_owner is not None and self.root_owner.running() else [] + if self.root_owner is not None: + self.owned_tree = list(dict.fromkeys(self.owned_tree + capture_owned_tree(self.root_owner, current_tree))) + tree = list({ + int(item["ProcessId"]): item + for item in self.initial_tree + current_tree + }.values()) + attempt = {"shutdownPath": "application-quit-event", "forced": False, "tree": tree, + "observerDetachedAfterWindowClose": False, "quitRequest": None, "survivorsBeforeCleanup": []} + self.stop_attempts.append(attempt) + try: + attempt["nativeWindowInventory"] = application_window_inventory(self.root_owner) if self.root_owner is not None else [] + if self.root_owner is not None and self.root_owner.running(): + if self.page is None or self.page.is_closed(): + raise RuntimeError("no live product page can request application quit") + attempt["quitRequest"] = {} + request_application_quit(self.page, attempt["quitRequest"]) + if self.browser is not None: + disconnect_cdp_observer(self.browser) + self.browser = None + attempt["observerDetachedAfterWindowClose"] = bool(attempt["quitRequest"]) + try: + self.process.wait(timeout=20) + except subprocess.TimeoutExpired: + attempt["forced"] = True + terminate_process_tree(self.process.pid) + attempt["rootReturnCode"] = self.process.poll() + deadline = time.monotonic() + 10 + survivors = [item for item in self.owned_tree if item.running()] + while survivors and time.monotonic() < deadline: + time.sleep(0.05) + survivors = [item for item in survivors if item.running()] + if survivors: + attempt["survivorsBeforeCleanup"] = [ + {"pid": item.pid, "creationTime": item.creation_time, "executable": item.executable} + for item in survivors + ] + attempt["forced"] = True + for item in survivors: + with contextlib.suppress(Exception): + terminate_process_tree(item.pid) + raise RuntimeError(f"owned application process tree left survivors: {[item.pid for item in survivors]}") + if attempt["forced"] and require_graceful: + raise RuntimeError("the isolated Lithe instance required forced termination") + if require_graceful and self.process.returncode != 0: + raise RuntimeError(f"the isolated Lithe instance exited with code {self.process.returncode}") + return {"forced": any(item["forced"] for item in self.stop_attempts), "tree": tree, + "attempts": list(self.stop_attempts)} + except Exception as error: + attempt["error"] = f"{type(error).__name__}: {error}" + if self.root_owner is not None and (self.root_owner.running() + or any(item.running() for item in self.owned_tree)): + attempt["forced"] = True + try: + terminate_process_tree(self.process.pid) + except Exception as cleanup_error: + attempt["cleanupError"] = f"{type(cleanup_error).__name__}: {cleanup_error}" + raise + finally: + attempt["rootReturnCode"] = self.process.poll() + for stream in (self.stdout_file, self.stderr_file): + if stream is not None: + stream.close() + self.log_root.mkdir(parents=True, exist_ok=True) + write_json(self.log_root / f"app-{self.sequence}.stop.json", self.stop_attempts) + + +user32 = ctypes.windll.user32 +EnumWindowsProc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) +user32.EnumWindows.argtypes = [EnumWindowsProc, wintypes.LPARAM] +user32.EnumChildWindows.argtypes = [wintypes.HWND, EnumWindowsProc, wintypes.LPARAM] +user32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)] +user32.GetClassNameW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int] +user32.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int] +user32.GetWindowTextW.restype = ctypes.c_int +user32.GetWindow.argtypes = [wintypes.HWND, wintypes.UINT] +user32.GetWindow.restype = wintypes.HWND +user32.IsWindow.argtypes = [wintypes.HWND] +user32.IsWindowVisible.argtypes = [wintypes.HWND] +user32.IsWindowEnabled.argtypes = [wintypes.HWND] +user32.GetDlgCtrlID.argtypes = [wintypes.HWND] +user32.GetDlgCtrlID.restype = ctypes.c_int +user32.GetParent.argtypes = [wintypes.HWND] +user32.GetParent.restype = wintypes.HWND + + +user32.SendMessageTimeoutW.argtypes = [ + wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPCWSTR, + wintypes.UINT, wintypes.UINT, ctypes.POINTER(ctypes.c_size_t), +] +user32.SendMessageTimeoutW.restype = wintypes.LPARAM +user32.PostMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] +user32.PostMessageW.restype = wintypes.BOOL + + +def window_pid(hwnd: int) -> int: + pid = wintypes.DWORD() + user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + return int(pid.value) + + +def window_class(hwnd: int) -> str: + buffer = ctypes.create_unicode_buffer(256) + user32.GetClassNameW(hwnd, buffer, len(buffer)) + return buffer.value + + +def find_file_dialog(app_pid: int): + matches = [] + + @EnumWindowsProc + def callback(hwnd, _): + if user32.IsWindowVisible(hwnd) and window_class(hwnd) == "#32770": + owner = user32.GetWindow(hwnd, 4) # GW_OWNER + if window_pid(hwnd) == app_pid or (owner and window_pid(owner) == app_pid): + matches.append(hwnd) + return True + + user32.EnumWindows(callback, 0) + return matches[-1] if matches else None + + +def child_windows(hwnd: int) -> list[int]: + children = [] + + @EnumWindowsProc + def callback(child, _): + children.append(child) + return True + + user32.EnumChildWindows(hwnd, callback, 0) + return children + + +def select_native_file(app_pid: int, file_path: Path) -> None: + dialog = wait_until(lambda: find_file_dialog(app_pid), 10, "the native package picker") + edits = [ + child + for child in child_windows(dialog) + if window_class(child) == "Edit" and user32.IsWindowVisible(child) and user32.IsWindowEnabled(child) + ] + if not edits: + classes = sorted({window_class(child) for child in child_windows(dialog)}) + raise RuntimeError(f"native package picker exposed no editable filename field; classes={classes}") + filename_edits = [ + edit for edit in edits if user32.GetDlgCtrlID(user32.GetParent(edit)) == 1148 + ] + if not filename_edits: + controls = [{"class": window_class(edit), "id": user32.GetDlgCtrlID(edit), + "parentId": user32.GetDlgCtrlID(user32.GetParent(edit))} for edit in edits] + raise RuntimeError(f"the native filename combo was not found: {controls}") + edit = filename_edits[0] + WM_SETTEXT = 0x000C + message_result = ctypes.c_size_t() + kernel32.SetLastError(0) + if not user32.SendMessageTimeoutW( + edit, WM_SETTEXT, 0, str(file_path), 0x0002, 5000, ctypes.byref(message_result) + ): + error_code = kernel32.GetLastError() + controls = [ + {"class": window_class(child), "id": user32.GetDlgCtrlID(child)} + for child in child_windows(dialog) + ] + raise RuntimeError( + f"native filename input failed within 5 seconds (Win32 {error_code}); controls={controls}" + ) + if not user32.PostMessageW(dialog, 0x0111, 1, 0): # WM_COMMAND / IDOK + raise RuntimeError("the native package picker rejected its Open command") + wait_until(lambda: not user32.IsWindow(dialog), 10, "the native package picker to close") + + +def finish_onboarding(page: Page) -> None: + done = page.get_by_role("button", name="Done", exact=True) + if done.count() > 0 and done.first.is_visible(): + done.first.click() + done.first.wait_for(state="detached", timeout=15_000) + + +def open_plugin_settings(page: Page) -> None: + category = page.locator("[data-settings-category='declarative-plugins']") + if category.count() == 0 or not category.first.is_visible(): + # A clean profile can select the None keymap during onboarding. Use + # the real toolbar action, whose visibility also waits for React to + # publish the workbench after the native invoke boundary is ready. + page.get_by_role("button", name="Settings", exact=True).first.click(timeout=15_000) + category.first.wait_for(state="visible", timeout=15_000) + category.first.click() + page.locator("[data-settings-content='declarative-plugins']").wait_for( + state="visible", timeout=15_000 + ) + + +def close_settings(page: Page) -> None: + content = page.locator("[data-settings-content='declarative-plugins']") + if content.count() and content.first.is_visible(): + page.get_by_role("button", name="Close", exact=True).last.click() + content.first.wait_for(state="hidden", timeout=15_000) + + +def plugin_row(page: Page): + # List children are state groups; identify the actual installed package, + # including when a bundled package has the same ID or display name. + return page.locator( + "[data-testid='declarative-plugin-list'] " + f"[data-plugin-id='{PLUGIN_ID}'][data-plugin-source='installed']" + ) + + +def select_plugin_details(page: Page, timeout_ms: int = 30_000): + row = plugin_row(page) + expect(row).to_have_count(1, timeout=timeout_ms) + row.get_by_role("button").click(timeout=timeout_ms) + # Management actions belong to the selected detail panel, outside the + # grouped list. Scope the region to settings so other surfaces cannot win. + details = page.locator("[data-settings-content='declarative-plugins']").get_by_role( + "region", name=DISPLAY_NAME, exact=True + ) + expect(details).to_be_visible(timeout=timeout_ms) + return details + + +def set_plugin_enabled(page: Page, enabled: bool, timeout_ms: int = 30_000) -> None: + details = select_plugin_details(page, timeout_ms) + details.get_by_role("button", name="Enable" if enabled else "Disable", exact=True).click( + timeout=timeout_ms + ) + # Native catalog publication can precede React's detail refresh. Do not + # attempt the inverse action until the selected panel reflects completion. + expect(details.get_by_role("button", name="Disable" if enabled else "Enable", exact=True)).to_be_visible( + timeout=timeout_ms + ) + + +def wait_catalog(page: Page, version: str | None = None, enabled: bool | None = None): + def predicate(): + catalog = invoke(page, "declarative_plugins_state") + match = next((item for item in catalog if item.get("pluginId") == PLUGIN_ID), None) + if not match: + return None + if version is not None and match.get("version") != version: + return None + if enabled is not None and match.get("enabled") is not enabled: + return None + return match + + return wait_until(predicate, 30, f"catalog state version={version} enabled={enabled}") + + +def import_review(page: Page, app_pid: int, package: Path): + open_plugin_settings(page) + page.get_by_role("button", name="Import package…", exact=True).click() + select_native_file(app_pid, package) + review = page.locator("[data-settings-content='declarative-plugins'] [role='dialog']") + review.wait_for(state="visible", timeout=30_000) + return review + + +def confirm_review(page: Page, review) -> None: + review.get_by_role("button", name="Install", exact=True).click() + review.wait_for(state="detached", timeout=45_000) + + +def cancel_review(page: Page, review) -> None: + review.get_by_role("button", name="Cancel", exact=True).click() + review.wait_for(state="detached", timeout=15_000) + + +def open_fixture_file(page: Page, file_name: str = "acceptance.litheaccept") -> None: + close_settings(page) + root = page.locator("[role='treeitem'][data-root='true']") + root.wait_for(state="visible", timeout=30_000) + if root.get_attribute("aria-expanded") == "false": + root.locator("[data-sidebar-tree-disclosure]").click() + expect(root).to_have_attribute("aria-expanded", "true", timeout=15_000) + existing = page.get_by_role("tab", name=re.compile(rf"^{re.escape(file_name)}")) + if existing.count() > 0: + # A restored editor can still be attaching its language server. Select + # it without introducing an unrelated close/reopen transaction into + # the application-restart scenario under test. + existing.first.click() + expect(existing.first).to_have_attribute("aria-selected", "true", timeout=15_000) + return + entry = page.get_by_text(file_name, exact=True) + entry.first.wait_for(state="visible", timeout=30_000) + entry.first.dblclick() + expect(page.get_by_role("tab", name=re.compile(rf"^{re.escape(file_name)}")).first).to_have_attribute( + "aria-selected", "true", timeout=15_000) + + +def evidence_files(state_root: Path, prefix: str) -> list[Path]: + if not state_root.exists(): + return [] + return sorted(state_root.rglob(f"{prefix}-*.json")) + + +def fixture_command_matches(command: str, state_root: Path) -> bool: + # Use Windows' own quoting rules; substring checks would also authorize + # an unrelated file whose name merely contains the fixture/state path. + shell32 = ctypes.windll.shell32 + shell32.CommandLineToArgvW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)] + shell32.CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR) + kernel32.LocalFree.argtypes = [wintypes.HLOCAL] + kernel32.LocalFree.restype = wintypes.HLOCAL + count = ctypes.c_int() + arguments = shell32.CommandLineToArgvW(command, ctypes.byref(count)) + if not arguments: + return False + try: + values = [arguments[index] for index in range(count.value)] + if not any(Path(value).name.lower() == "fake-lsp-server.mjs" for value in values[1:]): + return False + index = values.index("--state-dir") + candidate = Path(values[index + 1]).resolve() + root = state_root.resolve() + return candidate == root or root in candidate.parents + except (ValueError, IndexError, OSError): + return False + finally: + kernel32.LocalFree(arguments) + + +def live_fixture_pids(state_root: Path) -> set[int]: + pids = set() + for evidence in evidence_files(state_root, "server-active") + evidence_files( + state_root, "child-active" + ): + with contextlib.suppress(Exception): + pid = int(json.loads(evidence.read_text(encoding="utf-8"))["pid"]) + known_fixture = fixture_processes.get(pid) + if known_fixture is not None and known_fixture.running(): + pids.add(pid) + continue + if not process_exists(pid): + continue + record = next((item for item in process_tree(pid) if int(item["ProcessId"]) == pid), None) + if record is None or not fixture_command_matches(record.get("CommandLine") or "", state_root): + unverified_fixture_pids.add(pid) + continue + owner = remember_owned_process(pid, record) + if owner is not None and owner.running(): + fixture_processes[pid] = owner + pids.add(pid) + else: + unverified_fixture_pids.add(pid) + return pids + + +def wait_for_server_count(state_root: Path, count: int) -> set[int]: + return wait_until( + lambda: (pids if len(pids := live_fixture_pids(state_root)) >= count else None), + 45, + f"{count} controlled language-server process tree(s)", + ) + + +def wait_pids_dead(pids: set[int], description: str) -> None: + wait_until( + lambda: all(not fixture_processes[pid].running() for pid in pids if pid in fixture_processes), + 30, + description, + ) + + +def installation_record(app_local_data: Path) -> dict: + path = app_local_data / "plugins" / PLUGIN_ID / "installation.json" + return json.loads(path.read_text(encoding="utf-8")) + + +def active_fixture_marker(app_local_data: Path, version: str) -> str: + return ( + app_local_data / "plugins" / PLUGIN_ID / version / "fixture-version.txt" + ).read_text(encoding="utf-8").strip() + + +def newest_snapshot(app_local_data: Path) -> Path: + root = app_local_data / "plugin-temp" + snapshots = [path for path in root.glob("lithe-plugin-disk-*") if path.is_dir()] + if not snapshots: + raise RuntimeError("prepared install did not create a private snapshot") + return max(snapshots, key=lambda path: path.stat().st_mtime_ns) + + +def capture_session_failure(session: AppSession | None, artifact_root: Path) -> None: + if session is None or session.page is None: + return + with contextlib.suppress(Exception): + (artifact_root / "screenshots").mkdir(parents=True, exist_ok=True) + session.page.screenshot(path=str(artifact_root / "screenshots" / "failure.png"), timeout=5000) + with contextlib.suppress(Exception): + write_json(artifact_root / "page-failure.json", { + "url": session.page.url, + "text": session.page.locator("body").inner_text(timeout=5000), + }) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--executable", required=True, type=Path) + parser.add_argument("--run-image", required=True, type=Path) + parser.add_argument("--artifact-root", required=True, type=Path) + parser.add_argument("--report", required=True, type=Path) + parser.add_argument("--app-local-data", required=True, type=Path) + parser.add_argument("--app-roaming-data", required=True, type=Path) + parser.add_argument("--webview-data", required=True, type=Path) + parser.add_argument("--package-v1-a", required=True, type=Path) + parser.add_argument("--package-v1-b", required=True, type=Path) + parser.add_argument("--package-v2", required=True, type=Path) + parser.add_argument("--workspace-one", required=True, type=Path) + parser.add_argument("--workspace-two", required=True, type=Path) + parser.add_argument("--lsp-lifecycle", action="store_true", + help="Require fresh owned didOpen evidence for retained and reopened editors.") + from marketplace_acceptance import add_arguments, validate_arguments + add_arguments(parser) + from build_acceptance import add_arguments as add_build_arguments, validate_arguments as validate_build_arguments + add_build_arguments(parser) + from settings_functions_acceptance import add_arguments as add_settings_arguments, validate_arguments as validate_settings_arguments + add_settings_arguments(parser) + args = parser.parse_args() + validate_arguments(parser, args) + validate_build_arguments(parser, args) + validate_settings_arguments(parser, args) + + artifact_root = args.artifact_root.resolve() + artifact_root.mkdir(parents=True, exist_ok=True) + reporter = Reporter(artifact_root / "progress.json") + reporter.document_pending( + "windows-arm64 native acceptance", + "No Windows arm64 native execution environment is available; x64 compilation or emulation is not acceptance evidence.", + ) + reporter.document_pending( + "macOS native acceptance", + "This driver is intentionally Windows/WebView2-only; macOS requires its own product acceptance lane.", + ) + if not args.marketplace: + reporter.document_pending( + "roadmap M2 marketplace package acceptance", + "The optional real HTTPS marketplace lane was not requested; use --marketplace. Native package transactions additionally need the controlled fixture and an already-trusted TLS endpoint.", + ) + if not args.build_fixture_root: + reporter.document_pending( + "roadmap M4 non-provider build acceptance", + "Not requested. Generate build_fixture.py inputs and pass --build-fixture-root; closed capabilities stay pending.", + ) + reporter.document_pending("roadmap M5 build-system provider acceptance", + "Provider execution is outside the non-provider build fixture lane and remains pending.") + resource_snapshot = hash_tree + if args.settings_functions_fixture_root: + from settings_functions_acceptance import snapshot_resources + resource_snapshot = snapshot_resources + def package_checkpoint(label, version, archive): + from settings_functions_acceptance import verify_installed_archive + verify_installed_archive(args.app_local_data / "plugins" / PLUGIN_ID / version, + archive, artifact_root / "installed-package-invariance" / (label + ".json")) + + immutable_before = resource_snapshot(args.run_image.resolve()) + write_json(artifact_root / "resource-hashes-before.json", immutable_before) + session: AppSession | None = None + reporter.capture_failure = lambda: capture_session_failure(session, artifact_root) + all_owned_fixture_pids: set[int] = set() + cleanup_evidence = [] + failure = None + + local_parent = Path(os.environ["LOCALAPPDATA"]) + roaming_parent = Path(os.environ["APPDATA"]) + try: + with reporter.step("isolated application identity and writable roots"): + assert args.app_local_data.resolve() == (local_parent / ACCEPTANCE_IDENTIFIER).resolve() + assert args.app_roaming_data.resolve() == (roaming_parent / ACCEPTANCE_IDENTIFIER).resolve() + safe_remove_owned_directory(args.app_local_data, local_parent, ACCEPTANCE_IDENTIFIER) + safe_remove_owned_directory(args.app_roaming_data, roaming_parent, ACCEPTANCE_IDENTIFIER) + args.app_roaming_data.mkdir(parents=True) + write_json(args.app_roaming_data / "settings.json", {"displayLanguage": "en-US"}) + safe_remove_owned_directory( + args.webview_data, artifact_root.parent / "runtime", "webview2" + ) + args.webview_data.mkdir(parents=True) + assert "host-acceptance" in args.executable.name.lower() + + with sync_playwright() as playwright: + if args.settings_functions_fixture_root: + from settings_functions_acceptance import SettingsFunctionsAcceptance + + def restart_settings(workspace): + nonlocal session + if session is not None: + cleanup_evidence.append(session.stop(require_graceful=True)) + session = None + session = AppSession(playwright, args.executable.resolve(), workspace, + args.webview_data.resolve(), artifact_root / "logs", len(cleanup_evidence) + 1) + current = session.start() + finish_onboarding(current) + return current, session + + def stop_settings(): + nonlocal session + if session is not None: + cleanup_evidence.append(session.stop(require_graceful=True)) + session = None + + try: + SettingsFunctionsAcceptance(sys.modules[__name__], args, reporter, restart_settings, stop_settings).run() + finally: + stop_settings() + raise RuntimeError("M10 settings subset finished; real functions package/Provider prerequisites remain blocked. See settings-functions report rows.") + + with reporter.step("cancelled review discards its private snapshot"): + session = AppSession( + playwright, + args.executable.resolve(), + args.workspace_one.resolve(), + args.webview_data.resolve(), + artifact_root / "logs", + 1, + ) + page = session.start() + finish_onboarding(page) + review = import_review(page, session.process.pid, args.package_v1_a.resolve()) + assert "Unverified publisher" in review.inner_text() + cancel_review(page, review) + wait_until( + lambda: not list((args.app_local_data / "plugin-temp").glob("lithe-plugin-disk-*")), + 10, + "cancelled review snapshot cleanup", + ) + + with reporter.step("review exposes permissions and the complete program declaration"): + review = import_review(page, session.process.pid, args.package_v1_a.resolve()) + text = review.inner_text() + assert "languageServer.launch" in text + disclosure = review.get_by_role("button", name=re.compile(r"Program declaration \(\d+\)")) + disclosure.click() + declaration = review.locator("pre").inner_text() + assert '"kind": "runtimeLanguageServer"' in declaration + assert '"tool": "node"' in declaration + assert '"entry": "server/fake-lsp-server.mjs"' in declaration + (artifact_root / "screenshots").mkdir(parents=True, exist_ok=True) + page.screenshot(path=str(artifact_root / "screenshots" / "install-review.png")) + confirm_review(page, review) + installed = wait_catalog(page, "1.0.0", True) + write_json(artifact_root / "installed-catalog.json", installed) + assert installed["status"] == "accepted", installed.get("rejection") + record = installation_record(args.app_local_data) + assert record["schemaVersion"] == 2 + assert record["origin"] == "community" + assert [json.loads(program) for program in record["disk"]["approvedPrograms"]] == json.loads(declaration) + with args.package_v1_a.open("rb") as archive: + assert record["disk"]["archiveSha256"] == hashlib.file_digest(archive, "sha256").hexdigest() + + state_root = args.app_local_data / "plugin-state" / PLUGIN_ID + lifecycle = None + package_checkpoint("installed-v1a", "1.0.0", args.package_v1_a) + + if args.lsp_lifecycle: + from lsp_lifecycle_acceptance import LspLifecycleAcceptance + lifecycle = LspLifecycleAcceptance(sys.modules[__name__], args, reporter, all_owned_fixture_pids) + with reporter.step("installed plugin survives application restart"): + cleanup_evidence.append(session.stop(require_graceful=True)) + session = None + session = AppSession( + playwright, + args.executable.resolve(), + args.workspace_one.resolve(), + args.webview_data.resolve(), + artifact_root / "logs", + 2, + ) + page = session.start() + finish_onboarding(page) + open_plugin_settings(page) + wait_catalog(page, "1.0.0", True) + # Native catalog queries can finish before React publishes its list. + expect(plugin_row(page)).to_have_count(1, timeout=30_000) + + package_checkpoint("restarted-v1a", "1.0.0", args.package_v1_a) + + lsp_started = False + try: + with reporter.step("opening a declared language starts its server and child"): + open_fixture_file(page) + live = wait_for_server_count(state_root, 2) + all_owned_fixture_pids.update(live) + lsp_started = True + except Exception as error: + # Preserve the product failure while exercising independent install transactions. + failure = error + diagnostic = {} + for command in ("declarative_plugins_language_owner", "declarative_plugins_language_launch"): + try: + arguments = {"filePath": str(args.workspace_one.resolve() / "acceptance.litheaccept")} + if command.endswith("_launch"): + arguments["workspacePath"] = str(args.workspace_one.resolve()) + diagnostic[command] = invoke(page, command, arguments) + except Exception as diagnostic_error: + diagnostic[command] = {"error": str(diagnostic_error)} + write_json(artifact_root / "language-launch-diagnostic.json", diagnostic) + + if lsp_started: + with reporter.step("application exit revokes the language-server process tree"): + first_live = live_fixture_pids(state_root) + cleanup_evidence.append(session.stop(require_graceful=True)) + session = None + wait_pids_dead(first_live, "language-server and child cleanup on application exit") + package_checkpoint("lsp-exit-v1a", "1.0.0", args.package_v1_a) + session = AppSession( + playwright, args.executable.resolve(), args.workspace_one.resolve(), + args.webview_data.resolve(), artifact_root / "logs", 3, + ) + page = session.start() + finish_onboarding(page) + open_fixture_file(page) + all_owned_fixture_pids.update(wait_for_server_count(state_root, 2)) + if lifecycle is not None: + lifecycle.require_did_open({}, "1.0.0-a", "existing-current-attachment") + lifecycle.repeat_reopen(page, "1.0.0-a") + lifecycle.initialize_close_reopen(page, "1.0.0-a") + with reporter.step("two product windows revoke both owned process trees on disable"): + # The event wait pumps Playwright's protocol dispatcher; + # polling its cached pages list cannot receive a new target. + with page.context.expect_page(timeout=30_000) as new_page: + second_label = invoke(page, "create_app_window", {"request": { + "type": "directory", "path": str(args.workspace_two.resolve()), + }}) + second = new_page.value + second.wait_for_load_state("domcontentloaded", timeout=15_000) + second.wait_for_function("() => Boolean(window.__TAURI_INTERNALS__?.invoke)", timeout=15_000) + write_json(artifact_root / "second-product-window.json", { + "label": second_label, "url": second.url, + "workspace": str(args.workspace_two.resolve()), + }) + finish_onboarding(second) + open_fixture_file(second) + live = wait_for_server_count(state_root, 4) + all_owned_fixture_pids.update(live) + open_plugin_settings(page) + retained_offsets = lifecycle.snapshot() if lifecycle is not None else None + set_plugin_enabled(page, False) + wait_catalog(page, "1.0.0", False) + wait_pids_dead(live, "both windows' language-server process trees after disable") + set_plugin_enabled(page, True) + wait_catalog(page, "1.0.0", True) + if lifecycle is not None: + lifecycle.retained(page, "1.0.0-a", "disable and enable", retained_offsets) + else: + reporter.document_pending( + "application exit revokes the language-server process tree", + "The declared language did not activate its server; no process cleanup success is claimed.", + "plugin-host-acceptance / native-windows-x64", + ) + reporter.document_pending( + "two product windows revoke both owned process trees on disable", + "The declared language did not activate its server; process revocation was not exercised.", + "plugin-host-acceptance / native-windows-x64", + ) + + with reporter.step("approval-bound bytes reject post-review tampering"): + review = import_review(page, session.process.pid, args.package_v2.resolve()) + snapshot = newest_snapshot(args.app_local_data) + server = snapshot / "server" / "fake-lsp-server.mjs" + server.write_text(server.read_text(encoding="utf-8") + "\n// tampered after review\n", encoding="utf-8") + review.get_by_role("button", name="Install", exact=True).click() + review.wait_for(state="detached", timeout=45_000) + alert = page.locator("[data-settings-content='declarative-plugins'] [role='alert']") + alert.wait_for(state="visible", timeout=15_000) + assert re.search(r"(changed|digest|review|bytes)", alert.inner_text(), re.IGNORECASE) + wait_catalog(page, "1.0.0", True) + assert active_fixture_marker(args.app_local_data, "1.0.0") == "1.0.0-a" + wait_until( + lambda: not list((args.app_local_data / "plugin-temp").glob("lithe-plugin-disk-*")), + 10, + "rejected tampered snapshot cleanup", + ) + + with reporter.step("same-version replacement, update, and rollback preserve provenance"): + package_checkpoint("before-replacement-v1a", "1.0.0", args.package_v1_a) + retained_offsets = lifecycle.snapshot() if lifecycle is not None else None + review = import_review(page, session.process.pid, args.package_v1_b.resolve()) + confirm_review(page, review) + wait_catalog(page, "1.0.0", True) + assert active_fixture_marker(args.app_local_data, "1.0.0") == "1.0.0-b" + record = installation_record(args.app_local_data) + assert record["version"] == "1.0.0" + with args.package_v1_b.open("rb") as archive: + replacement_digest = hashlib.file_digest(archive, "sha256").hexdigest() + assert record["disk"]["archiveSha256"] == replacement_digest + if lifecycle is not None: + lifecycle.retained(page, "1.0.0-b", "same-version replacement", retained_offsets) + package_checkpoint("after-replacement-v1b", "1.0.0", args.package_v1_b) + + retained_offsets = lifecycle.snapshot() if lifecycle is not None else None + review = import_review(page, session.process.pid, args.package_v2.resolve()) + confirm_review(page, review) + wait_catalog(page, "1.1.0", True) + record = installation_record(args.app_local_data) + assert record["version"] == "1.1.0" + assert record["previousVersion"] == "1.0.0" + assert record["previousDisk"]["archiveSha256"] == replacement_digest + assert active_fixture_marker(args.app_local_data, "1.1.0") == "1.1.0" + if lifecycle is not None: + lifecycle.retained(page, "1.1.0", "update", retained_offsets) + open_plugin_settings(page) + package_checkpoint("after-upgrade-v2", "1.1.0", args.package_v2) + + retained_offsets = lifecycle.snapshot() if lifecycle is not None else None + select_plugin_details(page).get_by_role("button", name="Roll back…", exact=True).click() + wait_catalog(page, "1.0.0", True) + assert active_fixture_marker(args.app_local_data, "1.0.0") == "1.0.0-b" + assert installation_record(args.app_local_data)["disk"]["archiveSha256"] == replacement_digest + if lifecycle is not None: + lifecycle.retained(page, "1.0.0-b", "rollback", retained_offsets) + package_checkpoint("after-rollback-v1b", "1.0.0", args.package_v1_b) + + with reporter.step("uninstall removes package and state"): + live = set() + if lsp_started: + # Exercise uninstall with a genuinely new document after + # rollback, independently of restored-editor reactivation. + if lifecycle is not None: + lifecycle.select_existing(page) + else: + open_fixture_file(page, "uninstall.litheaccept") + live = wait_for_server_count(state_root, 2) + all_owned_fixture_pids.update(live) + open_plugin_settings(page) + package_checkpoint("before-uninstall-v1b", "1.0.0", args.package_v1_b) + select_plugin_details(page).get_by_role("button", name="Uninstall", exact=True).click() + expect(plugin_row(page)).to_have_count(0, timeout=45_000) + if live: + with reporter.step("uninstall revokes the language-server process tree"): + wait_pids_dead(live, "language-server process cleanup on uninstall") + else: + reporter.document_pending( + "uninstall revokes the language-server process tree", + "The declared language did not activate its server; no process cleanup success is claimed.", + "plugin-host-acceptance / native-windows-x64", + ) + assert not (args.app_local_data / "plugins" / PLUGIN_ID).exists() + assert not (args.app_local_data / "plugin-state" / PLUGIN_ID).exists() + + if args.marketplace: + from marketplace_acceptance import MarketplaceAcceptance + + def restart_marketplace(): + nonlocal session + cleanup_evidence.append(session.stop(require_graceful=True)) + session = None + wait_pids_dead(all_owned_fixture_pids, "fixture processes before marketplace restart") + sequence = len(cleanup_evidence) + 1 + session = AppSession( + playwright, args.executable.resolve(), args.workspace_one.resolve(), + args.webview_data.resolve(), artifact_root / "logs", sequence, + ) + current = session.start() + finish_onboarding(current) + return current + + # The disk lane deliberately opened two workspaces. Start the + # marketplace with one window so restoring an old second editor + # cannot introduce an extra language-server owner into its cases. + with reporter.step("marketplace isolated native session"): + page = restart_marketplace() + MarketplaceAcceptance( + sys.modules[__name__], args, reporter, page, + restart_marketplace, all_owned_fixture_pids, + ).run() + + if args.build_fixture_root: + from build_acceptance import BuildAcceptance + + def restart_build(workspace): + nonlocal session + cleanup_evidence.append(session.stop(require_graceful=True)) + session = None + session = AppSession(playwright, args.executable.resolve(), workspace, + args.webview_data.resolve(), artifact_root / "logs", len(cleanup_evidence) + 1) + current = session.start() + finish_onboarding(current) + return current, session + + BuildAcceptance(sys.modules[__name__], args, reporter, restart_build).run() + + with reporter.step("owned application and helper processes cleanly terminate"): + cleanup_evidence.append(session.stop(require_graceful=True)) + session = None + wait_pids_dead(all_owned_fixture_pids, "all recorded fixture processes") + write_json(artifact_root / "process-cleanup.json", cleanup_evidence) + + except Exception as error: # noqa: BLE001 - final cleanup and report must always run. + failure = failure or error + finally: + cleanup_errors = [] + if session is not None: + try: + cleanup_evidence.append(session.stop(require_graceful=False)) + except Exception as error: + cleanup_errors.append(str(error)) + owned_pids = all_owned_fixture_pids | live_fixture_pids( + args.app_local_data / "plugin-state" / PLUGIN_ID + ) + for pid in owned_pids: + try: + if owned_process_running(pid): + terminate_process_tree(pid) + except Exception as error: + cleanup_errors.append(str(error)) + write_json(artifact_root / "process-ownership-skipped.json", { + "unverifiedFixturePids": sorted(unverified_fixture_pids), + "action": "No termination authorized without a retained matching process handle.", + }) + for process in owned_processes.values(): + process.close() + owned_processes.clear() + fixture_processes.clear() + try: + if args.app_local_data.exists(): + evidence_copy = artifact_root / "isolated-app-data" + if evidence_copy.exists(): + shutil.rmtree(evidence_copy) + evidence_copy.mkdir(parents=True) + for name in ("logs", "plugins", "plugin-state", "plugin-temp", "settings.json"): + source = args.app_local_data / name + if source.is_dir(): + shutil.copytree(source, evidence_copy / name) + elif source.is_file(): + shutil.copy2(source, evidence_copy / name) + except Exception as error: + cleanup_errors.append(f"Could not preserve isolated app-data evidence: {error}") + for path, parent in ( + (args.app_local_data, local_parent), + (args.app_roaming_data, roaming_parent), + ): + try: + safe_remove_owned_directory(path, parent, ACCEPTANCE_IDENTIFIER) + except Exception as error: + cleanup_errors.append(str(error)) + try: + safe_remove_owned_directory( + args.webview_data, artifact_root.parent / "runtime", "webview2" + ) + except Exception as error: + cleanup_errors.append(str(error)) + write_json(artifact_root / "process-cleanup.json", cleanup_evidence) + if cleanup_errors: + reporter.tests.append( + { + "name": "failure-path cleanup", + "suite": "plugin-host-acceptance / native-windows-x64", + "status": "failed", + "durationMs": 0, + "details": "\n".join(cleanup_errors), + "maxMs": 30000, + } + ) + failure = failure or RuntimeError("; ".join(cleanup_errors)) + try: + with reporter.step("release executable and packaged resources remain byte-identical"): + immutable_after = resource_snapshot(args.run_image.resolve()) + write_json(artifact_root / "resource-hashes-after.json", immutable_after) + added = sorted(set(immutable_after) - set(immutable_before)) + removed = sorted(set(immutable_before) - set(immutable_after)) + changed = sorted( + path + for path in set(immutable_before) & set(immutable_after) + if immutable_before[path] != immutable_after[path] + ) + write_json( + artifact_root / "resource-hash-diff.json", + {"added": added, "removed": removed, "changed": changed}, + ) + assert not added and not removed and not changed + except Exception as error: + failure = failure or error + planned_cases = ( + "cancelled review discards its private snapshot", + "review exposes permissions and the complete program declaration", + "installed plugin survives application restart", + "opening a declared language starts its server and child", + "application exit revokes the language-server process tree", + "two product windows revoke both owned process trees on disable", + "approval-bound bytes reject post-review tampering", + "same-version replacement, update, and rollback preserve provenance", + "uninstall removes package and state", + "uninstall revokes the language-server process tree", + "owned application and helper processes cleanly terminate", + ) + completed_names = {test["name"] for test in reporter.tests} + for name in planned_cases: + if name not in completed_names: + reporter.tests.append( + { + "name": name, + "suite": "plugin-host-acceptance / native-windows-x64", + "status": "skipped", + "durationMs": 0, + "details": "An earlier acceptance step failed; this case was not executed.", + "maxMs": 120000, + } + ) + if args.marketplace: + from marketplace_acceptance import record_unreached_cases + record_unreached_cases(reporter) + if args.lsp_lifecycle: + from lsp_lifecycle_acceptance import record_unreached_cases + record_unreached_cases(reporter) + if args.build_fixture_root: + from build_acceptance import record_unreached_cases as record_build_cases, source_capabilities + from build_fixture import load_plan + record_build_cases(reporter, load_plan(args.build_fixture_root), source_capabilities(), + official=bool(args.build_official_maven_package), reason="No success is inferred from helper tests or source presence.") + if args.settings_functions_fixture_root: + from settings_functions_acceptance import record_unreached_cases as record_settings_cases + from settings_functions_fixture import load_plan as load_settings_plan + try: + record_settings_cases(reporter, load_settings_plan(args.settings_functions_fixture_root)) + except Exception as error: + # A missing/tampered plan must not prevent final report output + # after the native owner has completed bounded process cleanup. + reporter.tests.append({"name": "settings/functions input identity", "suite": "plugin-host-acceptance / settings-functions", + "status": "failed", "durationMs": 0, "maxMs": 10000, "details": str(error)}) + failure = failure or error + write_json( + args.report, + { + "runner": "acceptance", + "warnMs": 30_000, + "maxMs": 120_000, + "tests": reporter.tests, + }, + ) + + if failure is not None: + print(f"Windows plugin host acceptance failed: {failure}", file=sys.stderr) + return 1 + print("Windows plugin host acceptance passed with isolated application identity.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/acceptance_driver_test.py b/scripts/plugin-host-acceptance/acceptance_driver_test.py new file mode 100644 index 0000000000..6248f2f356 --- /dev/null +++ b/scripts/plugin-host-acceptance/acceptance_driver_test.py @@ -0,0 +1,765 @@ +#!/usr/bin/env python3 +"""Bounded driver checks, with an explicitly enabled browser DOM regression.""" + +from __future__ import annotations + +import argparse +import ctypes +import json +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +import acceptance_driver as driver +from acceptance_driver import ( + DISPLAY_NAME, PLUGIN_ID, OwnedProcess, fixture_command_matches, + plugin_row, process_exists, run_bounded_process, + select_plugin_details, set_plugin_enabled, +) +from playwright.sync_api import expect, sync_playwright + + +def run_case(name, action, tests, max_ms=10_000): + started = time.monotonic() + try: + action() + status = "passed" + details = "" + except Exception as error: # noqa: BLE001 - the report must retain any failure. + status = "failed" + details = f"{type(error).__name__}: {error}" + duration_ms = round((time.monotonic() - started) * 1000, 3) + if duration_ms >= max_ms: + status = "failed" + details = f"{details}; " if details else "" + details += f"test exceeded its {max_ms}ms budget ({duration_ms}ms)" + tests.append( + { + "name": name, + "suite": "plugin-host-acceptance / driver", + "status": status, + "durationMs": duration_ms, + "details": details, + "maxMs": max_ms, + } + ) + if status != "passed": + # Retain the failure and nonzero suite exit while still checking the + # remaining cleanup/identity/DOM regressions on an unhealthy machine. + print(f"FAIL {name}: {details}", file=sys.stderr) + + +def grouped_plugin_dom(browser_channel: str) -> None: + """Exercise the real locator API against grouped rows and external actions.""" + # The caller owns a process-level watchdog around this browser fixture; + # launch and DOM actions additionally retain short local deadlines. + with sync_playwright() as playwright: + browser = playwright.chromium.launch(channel=browser_channel, headless=True, timeout=10_000) + try: + page = browser.new_page() + page.set_default_timeout(2_000) + page.set_content(f""" +
+ +
+
+
    +
  • Enabled (3)

      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
  • +
+ +
+ """, timeout=2_000) + expect(plugin_row(page)).to_have_count(1) + expect(page.locator("[data-testid='declarative-plugin-list'] > li")).to_have_count(1) + details = select_plugin_details(page, timeout_ms=2_000) + expect(plugin_row(page).get_by_role("button")).to_have_attribute("aria-pressed", "true") + set_plugin_enabled(page, False, timeout_ms=2_000) + expect(details.get_by_role("button", name="Enable", exact=True)).to_be_visible() + set_plugin_enabled(page, True, timeout_ms=2_000) + expect(details.get_by_role("button", name="Disable", exact=True)).to_be_visible() + details.get_by_role("button", name="Roll back…", exact=True).click() + expect(page.locator("body")).to_have_attribute("data-rolled-back", "true") + details.get_by_role("button", name="Uninstall", exact=True).click() + expect(plugin_row(page)).to_have_count(0) + expect(page.locator("[data-plugin-source='bundled']")).to_have_count(1) + expect(page.locator("[data-plugin-id='io.example.other']")).to_have_count(1) + assert page.locator("body").get_attribute("data-trap") is None + page.set_content(''' + + + ''', timeout=2_000) + page.evaluate("document.addEventListener('keydown', () => {document.body.dataset.keyboardTrap='true'})") + driver.open_plugin_settings(page) + assert page.locator("body").get_attribute("data-keyboard-trap") is None + assert page.locator("[data-settings-content='declarative-plugins']").is_visible() + page.set_content(''' +
workspace
+
+ acceptance.litheaccept + +
+ ''', timeout=2_000) + driver.open_fixture_file(page) + expect(page.get_by_role("tab")).to_have_attribute("aria-selected", "true") + assert page.locator("body").get_attribute("data-closed") is None + finally: + browser.close() + + +def cdp_observer_detaches_only(browser_channel: str) -> None: + """Prove the pinned client's bounded close leaves its external browser alive.""" + port = driver.free_port() + with sync_playwright() as playwright: + browser = playwright.chromium.launch(channel=browser_channel, headless=True, timeout=10_000, + args=[f"--remote-debugging-port={port}"]) + try: + observer = playwright.chromium.connect_over_cdp(f"http://127.0.0.1:{port}", timeout=10_000) + driver.disconnect_cdp_observer(observer) + assert not observer.is_connected() + assert browser.is_connected(), "observer detach closed a browser it did not launch" + page = browser.new_page() + page.set_content("still-owned", timeout=2_000) + assert page.title() == "still-owned" + finally: + browser.close() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--report", type=Path) + parser.add_argument("--dom-browser", choices=["msedge"], help="Also run the HTML locator regression using this installed browser; no Lithe product is launched.") + parser.add_argument("--dom-fixture-child", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--observer-fixture-child", action="store_true", help=argparse.SUPPRESS) + args = parser.parse_args() + if args.dom_fixture_child: + if args.dom_browser is None: + parser.error("--dom-fixture-child requires --dom-browser") + grouped_plugin_dom(args.dom_browser) + return 0 + if args.observer_fixture_child: + if args.dom_browser is None: + parser.error("--observer-fixture-child requires --dom-browser") + cdp_observer_detaches_only(args.dom_browser) + return 0 + if args.report is None: + parser.error("--report is required") + tests = [] + + def completes(): + result = run_bounded_process( + [sys.executable, "-c", "print('bounded-ok')"], timeout_seconds=5 + ) + assert result.returncode == 0 + assert result.stdout.strip() == "bounded-ok" + + def output_is_utf8_and_loss_tolerant(): + result = run_bounded_process([ + sys.executable, "-c", + "import sys; " + "sys.stdout.buffer.write('进程输出'.encode('utf-8') + b'\\xff'); " + "sys.stderr.buffer.write('查询错误'.encode('utf-8') + b'\\xfe'); " + "sys.exit(1)", + ], timeout_seconds=5) + assert result.returncode == 1 + assert result.stdout == "进程输出\ufffd", repr(result.stdout) + assert result.stderr == "查询错误\ufffd", repr(result.stderr) + + def inspection_failure_is_not_an_empty_tree(): + with patch.object(driver, "process_tree_ids", return_value=[123]), patch.object(driver, "process_exit_proven", return_value=False): + with patch.object(driver.psutil, "Process", side_effect=driver.psutil.AccessDenied(123)): + try: + driver.process_tree(123) + except driver.psutil.AccessDenied: + pass + else: + raise AssertionError("inspection failure was accepted as an empty tree") + + def exited_identity_race_requires_positive_proof(): + process = MagicMock() + process.is_running.return_value = True + with patch.object(driver.psutil, "Process", return_value=process), patch.object(OwnedProcess, "capture", return_value=None): + with patch.object(driver, "process_exit_proven", return_value=True): + assert driver.process_record(123) is None + with patch.object(driver, "process_exit_proven", return_value=False): + try: + driver.process_record(123) + except RuntimeError as error: + assert "could not inspect" in str(error) + else: + raise AssertionError("live identity failure was swallowed") + with patch.object(driver.psutil, "Process", side_effect=driver.psutil.AccessDenied(123)): + with patch.object(driver, "process_exit_proven", return_value=True): + assert driver.process_record(123) is None + with patch.object(driver, "process_exit_proven", return_value=False): + try: + driver.process_record(123) + except driver.psutil.AccessDenied: + pass + else: + raise AssertionError("live AccessDenied owner was swallowed") + + def native_exit_proof_rejects_denied_or_failed_inspection(): + for state, expected in ((0, True), (driver.WAIT_TIMEOUT, False)): + with patch.object(driver.kernel32, "OpenProcess", return_value=456), patch.object(driver.kernel32, "WaitForSingleObject", return_value=state): + with patch.object(driver.kernel32, "CloseHandle") as close: + assert driver.process_exit_proven(123) is expected + close.assert_called_once_with(456) + for open_handle, error, state in ((0, 87, 0), (0, 5, 0), (456, 5, 0xFFFFFFFF)): + with patch.object(driver.kernel32, "OpenProcess", return_value=open_handle), patch.object(driver.kernel32, "GetLastError", return_value=error): + with patch.object(driver.kernel32, "WaitForSingleObject", return_value=state), patch.object(driver.kernel32, "CloseHandle") as close: + if error == 87: + assert driver.process_exit_proven(123) + else: + try: + driver.process_exit_proven(123) + except OSError as failure: + assert failure.winerror == 5 + else: + raise AssertionError("uninspectable process was reported exited") + assert close.call_count == bool(open_handle) + owner = OwnedProcess(123, 456, 1000, "fixture.exe") + with patch.object(driver.kernel32, "WaitForSingleObject", return_value=0xFFFFFFFF), patch.object(driver.kernel32, "GetLastError", return_value=5): + try: + owner.running() + except OSError as failure: + assert failure.winerror == 5 + else: + raise AssertionError("failed retained-handle wait was reported exited") + + def capture_tree_rejects_live_uninspectable_child(): + root = MagicMock(pid=101, creation_time=1000, descendants=[]) + root.running.return_value = True + records = [{"ProcessId": 101, "ParentProcessId": 0, "CreationTime": 1000}, + {"ProcessId": 102, "ParentProcessId": 101, "CreationTime": 1001}] + with patch.object(driver, "remember_owned_process", return_value=None): + with patch.object(driver, "process_exit_proven", return_value=True): + assert driver.capture_owned_tree(root, records) == [] + with patch.object(driver, "process_exit_proven", return_value=False): + try: + driver.capture_owned_tree(root, records) + except RuntimeError as error: + assert "could not retain the live process identity" in str(error) + else: + raise AssertionError("live uninspectable child was omitted") + + def short_lived_native_process_has_signaled_exit_proof(): + # Popen retains the original kernel object until context exit. Waiting + # establishes the race order without timing sleeps or PID-name cleanup. + with subprocess.Popen([sys.executable, "-c", "pass"], creationflags=driver.CREATE_NO_WINDOW) as child: + try: + child.wait(timeout=5) + assert driver.process_exit_proven(child.pid) + process = MagicMock() + process.is_running.return_value = True + with patch.object(driver.psutil, "Process", return_value=process), patch.object(OwnedProcess, "capture", return_value=None): + assert driver.process_record(child.pid) is None + finally: + if child.poll() is None: + child.kill() + child.wait(timeout=5) + + def metadata_rejects_pid_reuse_and_closes_identity(): + process, identity = MagicMock(), MagicMock() + process.is_running.return_value = False + identity.creation_time = 134000000000000001 + identity.executable = "C:/fixture/node.exe" + with patch.object(driver.psutil, "Process", return_value=process): + with patch.object(driver.OwnedProcess, "capture", return_value=identity): + assert driver.process_record(123) is None + identity.close.assert_called_once() + process.is_running.return_value = True + identity.running.return_value = True + process.ppid.return_value = 100 + process.name.return_value = "node.exe" + process.cmdline.return_value = ["node.exe", "中文 fixture.mjs"] + identity.reset_mock() + with patch.object(driver.psutil, "Process", return_value=process): + with patch.object(driver.OwnedProcess, "capture", return_value=identity): + record = driver.process_record(123) + assert record["CreationTime"] == 134000000000000001 + assert record["ParentProcessId"] == 100 + assert record["CommandLine"] == 'node.exe "中文 fixture.mjs"' + identity.close.assert_called_once() + + def topology_snapshot_closes_handles_and_rejects_errors(): + entries = iter([(101, 0), (102, 101), (103, 102), (999, 0)]) + + def next_entry(_handle, pointer): + try: + pid, parent_pid = next(entries) + except StopIteration: + return False + entry = ctypes.cast(pointer, ctypes.POINTER(driver.ProcessEntry32)).contents + assert entry.dwSize == ctypes.sizeof(driver.ProcessEntry32) + entry.th32ProcessID, entry.th32ParentProcessID = pid, parent_pid + return True + + with patch.object(driver.kernel32, "CreateToolhelp32Snapshot", return_value=456): + with patch.object(driver.kernel32, "Process32FirstW", side_effect=next_entry): + with patch.object(driver.kernel32, "Process32NextW", side_effect=next_entry): + with patch.object(driver.kernel32, "GetLastError", return_value=driver.ERROR_NO_MORE_FILES): + with patch.object(driver.kernel32, "CloseHandle") as close: + assert driver.process_tree_ids(101) == [101, 102, 103] + close.assert_called_once_with(456) + with patch.object(driver.kernel32, "Process32FirstW", return_value=False): + with patch.object(driver.kernel32, "GetLastError", return_value=5): + with patch.object(driver.kernel32, "CloseHandle") as close: + try: + driver.process_tree_ids(101) + except OSError as error: + assert error.winerror == 5 + else: + raise AssertionError("failed topology snapshot was accepted as an empty tree") + close.assert_called_once_with(456) + with patch.object(driver.kernel32, "CreateToolhelp32Snapshot", return_value=driver.INVALID_HANDLE_VALUE): + with patch.object(driver.kernel32, "GetLastError", return_value=5): + with patch.object(driver.kernel32, "CloseHandle") as close: + try: + driver.process_tree_ids(101) + except OSError as error: + assert error.winerror == 5 + else: + raise AssertionError("invalid snapshot handle was accepted") + close.assert_not_called() + + def wrapper_inventory_rejects_unrelated_lookalikes(): + executable = Path("C:/fixture/lithe-host-acceptance.exe") + state = Path(driver.os.environ["LOCALAPPDATA"]) / driver.ACCEPTANCE_IDENTIFIER / "plugin-state" / PLUGIN_ID + candidates = [] + records = {} + for pid, directory in ((101, state / "workspace"), (102, Path(str(state) + "-unrelated"))): + process = MagicMock(pid=pid) + process.info = {"pid": pid, "name": "node.exe"} + candidates.append(process) + records[pid] = {"ProcessId": pid, "ExecutablePath": "C:/runtime/node.exe", + "CommandLine": subprocess.list2cmdline([ + "node.exe", "C:/fixture/fake-lsp-server.mjs", "--state-dir", str(directory), + ])} + with patch.object(driver.psutil, "process_iter", return_value=candidates): + with patch.object(driver, "process_record", side_effect=records.get): + with patch.object(driver, "process_tree", side_effect=lambda pid: [records[pid]]) as tree: + assert driver.acceptance_process_inventory(executable, driver.ACCEPTANCE_IDENTIFIER) == [records[101]] + tree.assert_called_once_with(101) + def query_failure_still_cleans_retained_handles(): + class HandleOwner: + def __init__(self, pid): + self.pid = pid + self.alive = True + self.descendants = [] + self.terminated = False + self.creation_time = pid * 100 + + def running(self): + return self.alive + + def terminate(self): + self.terminated = True + self.alive = False + + root, child, unrelated = (HandleOwner(pid) for pid in (101, 102, 103)) + records = [ + {"ProcessId": root.pid, "ParentProcessId": 0, "CreationTime": root.creation_time}, + {"ProcessId": child.pid, "ParentProcessId": root.pid, "CreationTime": child.creation_time}, + ] + with patch.object(driver, "remember_owned_process", return_value=child): + assert driver.capture_owned_tree(root, records) == [child] + query_error = RuntimeError("CIM 查询失败") + with patch.object(driver, "owned_processes", {item.pid: item for item in (root, child, unrelated)}): + with patch.object(driver, "process_tree", side_effect=query_error): + try: + driver.terminate_process_tree(root.pid, timeout_seconds=0) + except RuntimeError as error: + assert error.__cause__ is query_error + assert "CIM 查询失败" in str(error) + assert "uninspected descendants may remain" in str(error) + else: + raise AssertionError("failed inspection reported complete tree cleanup") + assert root.terminated and child.terminated + assert unrelated.alive and not unrelated.terminated + + def cleanup_distinguishes_exiting_and_surviving_handles(): + class ExitingHandle: + pid = 123 + descendants = [] + + def __init__(self, survives): + self.alive = True + self.survives = survives + + def running(self): + return self.alive + + def terminate(self): + self.alive = self.survives + raise RuntimeError("controlled native termination denial") + + exited = ExitingHandle(False) + with patch.dict(driver.owned_processes, {exited.pid: exited}, clear=True): + driver.terminate_process_tree(exited.pid, timeout_seconds=0, inspect_descendants=False) + survivor = ExitingHandle(True) + with patch.dict(driver.owned_processes, {survivor.pid: survivor}, clear=True): + try: + driver.terminate_process_tree(survivor.pid, timeout_seconds=0, inspect_descendants=False) + except RuntimeError as error: + assert "controlled native termination denial" in str(error) + assert "owned process handles survived cleanup: [123]" in str(error) + else: + raise AssertionError("a surviving retained handle was accepted as clean") + + def timeout_query_failure_keeps_error_and_reaps_root(): + real_popen = driver.subprocess.Popen + spawned = [] + + def capture_popen(*positional, **keyword): + process = real_popen(*positional, **keyword) + spawned.append(process) + return process + + try: + with patch.object(driver.subprocess, "Popen", side_effect=capture_popen): + with patch.object(driver, "process_tree", side_effect=RuntimeError("CIM unavailable")): + try: + run_bounded_process([ + sys.executable, "-c", "import time; time.sleep(60)", + ], timeout_seconds=0.05) + except TimeoutError as error: + assert "CIM unavailable" in str(error), str(error) + assert "uninspected descendants may remain" in str(error), str(error) + else: + raise AssertionError("bounded process did not time out") + assert len(spawned) == 1 + assert spawned[0].poll() is not None, "retained root survived failed inspection" + assert spawned[0].pid not in driver.owned_processes, "root ownership was not released after cleanup" + finally: + for process in spawned: + if process.poll() is None: + process.kill() + process.wait(timeout=3) + + def timeout_cleans_tree(): + args.report.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory( + prefix="lithe-host-driver-test-", dir=args.report.parent + ) as directory: + pid_file = Path(directory) / "child.pid" + script = ( + "import json,pathlib,subprocess,sys,time; " + f"sys.path.insert(0,{str(Path(__file__).resolve().parent)!r}); " + "from acceptance_driver import OwnedProcess; " + "p=subprocess.Popen([sys.executable,'-c','import time; time.sleep(60)']); " + "owner=OwnedProcess.capture(p.pid); assert owner is not None; " + f"pathlib.Path({str(pid_file)!r}).write_text(json.dumps(" + "{'pid':p.pid,'CreationTime':owner.creation_time,'ExecutablePath':owner.executable}),encoding='utf-8'); " + "time.sleep(60)" + ) + child_owner = None + command = [sys.executable, "-B", "-c", script] + communicate = subprocess.Popen.communicate + + def fixture_ready(): + try: + identity = json.loads(pid_file.read_text(encoding="utf-8")) + return bool(identity.get("pid") and identity.get("CreationTime") and identity.get("ExecutablePath")) + except (FileNotFoundError, json.JSONDecodeError): + return False + + def communicate_after_ready(process, *arguments, **keywords): + # This case checks an already-created tree. Arm its timeout only + # after the fixture's child identity is complete, not after an + # assumed Python startup duration. Failure still enters the + # production timeout cleanup with its retained root handle. + if process.args == command: + try: + driver.wait_until(fixture_ready, 5, "the timeout fixture child identity") + except (TimeoutError, OSError) as error: + raise subprocess.TimeoutExpired(command, 5) from error + return communicate(process, *arguments, **keywords) + + try: + try: + with patch.object(subprocess.Popen, "communicate", communicate_after_ready): + run_bounded_process(command, timeout_seconds=1) + except TimeoutError as error: + timeout_error = str(error) + else: + raise AssertionError("bounded process did not time out") + deadline = time.monotonic() + 5 + while not pid_file.exists() and time.monotonic() < deadline: + time.sleep(0.02) + assert pid_file.exists(), "fixture child PID was not recorded" + identity = json.loads(pid_file.read_text(encoding="utf-8")) + child_pid = identity["pid"] + # Keep teardown ownership separate from the driver's cached + # tree, so it cannot make the behavior under test succeed. + child_owner = OwnedProcess.capture(child_pid, identity) + deadline = time.monotonic() + 5 + while process_exists(child_pid) and time.monotonic() < deadline: + time.sleep(0.05) + assert not process_exists(child_pid), f"timed-out child {child_pid} survived cleanup: {timeout_error}" + assert "cleanup could not be confirmed" not in timeout_error, timeout_error + finally: + if child_owner is None and pid_file.exists(): + identity = json.loads(pid_file.read_text(encoding="utf-8")) + child_owner = OwnedProcess.capture(identity["pid"], identity) + if child_owner is not None: + try: + child_owner.terminate() + driver.wait_until(lambda: not child_owner.running(), 3, "the fixture child's final cleanup", interval=0.02) + finally: + child_owner.close() + + def retained_handle_ignores_changed_pid(): + first = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + second = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + owner = None + try: + owner = OwnedProcess.capture(first.pid) + assert owner is not None + # Model PID reuse without depending on the OS actually recycling it. + owner.pid = second.pid + owner.terminate() + first.wait(timeout=3) + assert second.poll() is None, "termination followed a recycled PID instead of the retained handle" + finally: + if owner is not None: + owner.close() + for process in (first, second): + if process.poll() is None: + process.kill() + process.wait(timeout=3) + + def mismatched_identity_is_not_owned(): + process = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + original = rejected = None + try: + original = OwnedProcess.capture(process.pid) + assert original is not None + rejected = OwnedProcess.capture(process.pid, { + "CreationTime": original.creation_time - 100, + "ExecutablePath": original.executable, + }) + assert rejected is None + rejected = OwnedProcess.capture(process.pid, { + "CreationTime": original.creation_time, + "ExecutablePath": original.executable + ".unrelated", + }) + assert rejected is None + assert process.poll() is None + finally: + if original is not None: + original.close() + if rejected is not None: + rejected.close() + if process.poll() is None: + process.kill() + process.wait(timeout=3) + + def fixture_arguments_are_exact(): + root = Path(tempfile.gettempdir()) / "lithe fixture state" + valid = subprocess.list2cmdline(["node.exe", "C:/fixture/fake-lsp-server.mjs", "--state-dir", str(root)]) + assert fixture_command_matches(valid, root) + assert not fixture_command_matches(valid.replace("fake-lsp-server.mjs", "other-fake-lsp-server.mjs"), root) + sibling = subprocess.list2cmdline(["node.exe", "C:/fixture/fake-lsp-server.mjs", "--state-dir", str(root) + "-unrelated"]) + assert not fixture_command_matches(sibling, root) + + def executable_alias_retains_identity(): + process = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + original = alias_owner = None + try: + original = OwnedProcess.capture(process.pid) + assert original is not None + # The extended Windows path names the same executable through a + # different spelling, without requiring junction privileges. + executable_alias = "\\\\?\\" + original.executable + alias_owner = OwnedProcess.capture(process.pid, { + "CreationTime": original.creation_time, + "ExecutablePath": executable_alias, + }) + assert alias_owner is not None, "a filesystem alias rejected the original process identity" + assert alias_owner.running() + assert alias_owner.creation_time == original.creation_time + finally: + for owner in (original, alias_owner): + if owner is not None: + owner.close() + if process.poll() is None: + process.kill() + process.wait(timeout=3) + + def grouped_plugin_actions(): + result = run_bounded_process([ + sys.executable, "-B", str(Path(__file__).resolve()), + "--dom-browser", args.dom_browser, "--dom-fixture-child", + ], timeout_seconds=30) + assert result.returncode == 0, result.stderr or result.stdout + + def observer_detach_preserves_external_browser(): + result = run_bounded_process([ + sys.executable, "-B", str(Path(__file__).resolve()), + "--dom-browser", args.dom_browser, "--observer-fixture-child", + ], timeout_seconds=30) + assert result.returncode == 0, result.stderr or result.stdout + + def application_quit_uses_all_product_labels_and_waits_for_real_close(): + page = MagicMock() + labels = ["main", "project-2", "hidden-launcher"] + with patch.object(driver, "invoke", return_value=labels) as invoke: + result = driver.request_application_quit(page) + invoke.assert_called_once_with(page, "plugin:window|get_all_windows") + page.expect_event.assert_called_once_with("close", timeout=15_000) + script, emitted = page.evaluate.call_args.args + assert emitted == labels + assert 'Promise.all(labels.map(label' in script + assert '"plugin:event|emit_to"' in script and 'event: "menu_quit_app"' in script + assert 'kind: "AnyLabel", label' in script + assert result == {"windowLabels": labels, "observedProductPageClosed": True, "dispatch": "acknowledged"} + page.reset_mock() + page.evaluate.side_effect = driver.TargetClosedError("fixture page closed during acknowledgement") + with patch.object(driver, "invoke", return_value=labels): + assert driver.request_application_quit(page)["observedProductPageClosed"] is True + page.expect_event.assert_called_once_with("close", timeout=15_000) + page.evaluate.side_effect = RuntimeError("fixture permission denied") + with patch.object(driver, "invoke", return_value=labels): + try: + driver.request_application_quit(page) + except RuntimeError as error: + assert str(error) == "fixture permission denied" + else: + raise AssertionError("a rejected quit dispatch was accepted") + page.reset_mock() + with patch.object(driver, "invoke", return_value=[]): + try: + driver.request_application_quit(page) + except RuntimeError as error: + assert "WebviewWindow labels" in str(error) + else: + raise AssertionError("an empty product window list was accepted") + page.evaluate.assert_not_called() + + def session_observes_quit_before_detach_and_keeps_failed_cleanup_evidence(): + with tempfile.TemporaryDirectory(prefix="lithe-session-stop-") as directory: + root = Path(directory) + session = driver.AppSession(None, root / "app.exe", root, root / "webview", root / "logs", 1) + owner = MagicMock(pid=101) + owner.running.return_value = True + session.root_owner = owner + session.process = MagicMock(pid=101, returncode=0) + session.process.poll.return_value = 0 + session.browser = MagicMock() + session.page = MagicMock() + session.page.is_closed.return_value = False + events = [] + def waited(**_): + events.append("wait") + owner.running.return_value = False + session.process.wait.side_effect = waited + with patch.object(driver, "process_tree", return_value=[]): + with patch.object(driver, "capture_owned_tree", return_value=[]): + with patch.object(driver, "application_window_inventory", side_effect=lambda _: events.append("inventory") or []): + with patch.object(driver, "disconnect_cdp_observer", side_effect=lambda _: events.append("disconnect")): + def request_quit(_page, evidence): + events.append("quit-closed") + evidence.update({"windowLabels": ["main", "hidden"], "observedProductPageClosed": True}) + with patch.object(driver, "request_application_quit", side_effect=request_quit): + result = session.stop() + assert events == ["inventory", "quit-closed", "disconnect", "wait"], events + assert result["forced"] is False + assert result["attempts"][0]["quitRequest"]["windowLabels"] == ["main", "hidden"] + assert result["attempts"][0]["observerDetachedAfterWindowClose"] is True + + child = MagicMock(pid=102, creation_time=12345, executable="C:/fixture/webview.exe") + child.running.return_value = True + session.owned_tree = [child] + clock = iter(range(0, 1000, 20)) + def terminate(_): + child.running.return_value = False + with patch.object(driver.time, "monotonic", side_effect=lambda: next(clock)): + with patch.object(driver, "terminate_process_tree", side_effect=terminate): + try: + session.stop() + except RuntimeError as error: + assert "left survivors: [102]" in str(error) + else: + raise AssertionError("forced descendant cleanup was accepted as graceful") + final = session.stop(require_graceful=False) + assert final["forced"] is True, "a later cleanup attempt hid the original forced cleanup" + assert final["attempts"][1]["survivorsBeforeCleanup"] == [ + {"pid": 102, "creationTime": 12345, "executable": "C:/fixture/webview.exe"}] + recorded = json.loads((root / "logs/app-1.stop.json").read_text(encoding="utf-8")) + assert recorded[1]["forced"] is True and "left survivors" in recorded[1]["error"] + + try: + run_case("bounded process captures a successful child", completes, tests) + run_case("process output decodes UTF-8 and replaces malformed bytes", output_is_utf8_and_loss_tolerant, tests) + run_case("inspection errors cannot become an empty process tree", inspection_failure_is_not_an_empty_tree, tests) + run_case("exited identity races require positive proof and live owners fail", exited_identity_race_requires_positive_proof, tests) + run_case("native exit proof rejects denied handles and failed waits", native_exit_proof_rejects_denied_or_failed_inspection, tests) + run_case("tree retention skips proven exit but rejects a live uninspectable child", capture_tree_rejects_live_uninspectable_child, tests) + run_case("short-lived native process exposes a signaled original object", short_lived_native_process_has_signaled_exit_proof, tests) + run_case("metadata rejects PID reuse and preserves exact native identity", metadata_rejects_pid_reuse_and_closes_identity, tests) + run_case("topology snapshots close their handles and reject inspection errors", topology_snapshot_closes_handles_and_rejects_errors, tests) + run_case("wrapper inventory rejects unrelated fixture path lookalikes", wrapper_inventory_rejects_unrelated_lookalikes, tests) + run_case("query failure cleans retained handles without touching unrelated processes", query_failure_still_cleans_retained_handles, tests) + run_case("cleanup accepts a signaled original handle but rejects a surviving handle", cleanup_distinguishes_exiting_and_surviving_handles, tests) + run_case("timeout preserves query failure and reaps its retained root", timeout_query_failure_keeps_error_and_reaps_root, tests) + run_case("timeout terminates the owned process tree", timeout_cleans_tree, tests) + run_case("termination targets a retained handle despite a changed PID", retained_handle_ignores_changed_pid, tests) + run_case("creation-time and executable mismatches reject ownership", mismatched_identity_is_not_owned, tests) + run_case("fixture arguments reject substring lookalikes", fixture_arguments_are_exact, tests) + run_case("filesystem aliases preserve the retained process identity", executable_alias_retains_identity, tests) + run_case("application quit uses all product labels and waits for real close", application_quit_uses_all_product_labels_and_waits_for_real_close, tests) + run_case("session observes quit before detach and retains failed cleanup evidence", session_observes_quit_before_detach_and_keeps_failed_cleanup_evidence, tests) + if args.dom_browser is not None: + run_case("grouped installed rows select scoped detail actions", grouped_plugin_actions, tests, max_ms=35_000) + run_case("CDP observer detach leaves the external browser alive", observer_detach_preserves_external_browser, tests, max_ms=35_000) + else: + tests.append({ + "name": "grouped installed rows select scoped detail actions", + "suite": "plugin-host-acceptance / driver", + "status": "skipped", + "durationMs": 0, + "maxMs": 35_000, + "details": "Not requested; pass --dom-browser msedge to exercise the lightweight HTML locator regression. This is separate from native product acceptance.", + }) + finally: + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text( + json.dumps( + { + "runner": "acceptance", + "warnMs": 2_000, + "maxMs": 10_000, + "tests": tests, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + return 1 if any(test["status"] == "failed" for test in tests) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/build_acceptance.py b/scripts/plugin-host-acceptance/build_acceptance.py new file mode 100644 index 0000000000..1f6821d139 --- /dev/null +++ b/scripts/plugin-host-acceptance/build_acceptance.py @@ -0,0 +1,642 @@ +#!/usr/bin/env python3 +"""Opt-in real UI/IPC non-provider build acceptance; no native test backdoor. + +The planning command reports closed source prerequisites without connecting to +a product. The optional acceptance_driver lane only runs on an already staged +isolated product; existing native import, signatures and approvals remain gates. +""" + +from __future__ import annotations + +import argparse +import contextlib +import json +import os +import re +import uuid +import zipfile +from pathlib import Path + +from build_fixture import COMMAND, ROOT, confined, load_plan, ordinary_tree, tool_script + + +SUITE = "plugin-host-acceptance / native-build" +STEPS = ("install-review", "passive-detection", "saved-model", "decline-run", "task-test-dependencies", + "cancel-tree", "replace-late", "close-workspace", "read-only-resources", "uninstall") +OFFICIAL = "hostIntegration / official Maven model-task-test-dependencies" +SETTING_CASE = "setting / configured failure never falls back" +IDENTITY = ("operationId", "pluginId", "buildSystemId", "root", "workspaceRoot", "generation") + + +def source_capabilities(root: Path = ROOT) -> dict: + source = (root / "windows/tauri/src-tauri/src/plugin_host/mod.rs").read_text(encoding="utf-8") + match = re.search(r"pub fn host_capabilities\(\) -> Value\s*\{\s*json!\((\{.*?\})\)\s*\}", source, re.S) + if not match: + raise ValueError("cannot read the host's literal capability prerequisite; never infer enabled") + return json.loads(re.sub(r",\s*([}\]])", r"\1", match[1])) + + +def missing_capabilities(required: dict, capabilities: dict) -> list[str]: + return [f"{name}:{level}" for name, level in sorted(required.items()) if level not in capabilities.get(name, [])] + + +def planned_cases(plan: dict, capabilities: dict, official: bool = False) -> list[dict]: + rows = [] + for variant in plan["variants"]: + missing = missing_capabilities(variant["requires"], capabilities) + reason = ("Closed source prerequisites: " + ", ".join(missing)) if missing else "Requires opt-in real isolated product execution" + for step in STEPS: + rows.append({"name": f"{variant['id']} / {step}", "suite": SUITE, "status": "skipped", "durationMs": 0, + "maxMs": 120_000, "details": f"{reason}. Not executed. Install through the native review; never edit installation records."}) + rows.append({"name": OFFICIAL, "suite": SUITE, "status": "skipped", "durationMs": 0, "maxMs": 120_000, + "details": "Not executed. " + plan["hostIntegration"]["prerequisite"] + + (" Official archive supplied; native validation/approval still required." if official else " No official archive supplied.")}) + rows.append({"name": SETTING_CASE, "suite": SUITE, "status": "skipped", "durationMs": 0, "maxMs": 120_000, + "details": "Not executed. Requires settings/executableResolution enabled and the real failed-tool UI/IPC path."}) + return rows + + +def record_unreached_cases(reporter, plan: dict, capabilities: dict, *, official=False, reason=None): + completed = {test["name"] for test in reporter.tests} + for row in planned_cases(plan, capabilities, official): + if row["name"] not in completed: + if reason: + row["details"] += " " + reason + reporter.tests.append(row) + + +def add_arguments(parser) -> None: + parser.add_argument("--build-fixture-root", type=Path, help="Opt in to generated non-provider build fixtures; closed capabilities remain skipped.") + parser.add_argument("--build-official-maven-package", type=Path, help="Optional genuine official publisher-signed Maven archive; no test signature/trust override.") + + +def validate_arguments(parser, args) -> None: + if args.build_official_maven_package and not args.build_fixture_root: + parser.error("--build-official-maven-package requires --build-fixture-root") + + +@contextlib.contextmanager +def tool_environment(root: Path): + tools = confined(root, "tools with spaces/bin") + prior = {key: os.environ.get(key) for key in ("PATH", "LITHE_ACCEPTANCE_TOOL_HOME")} + os.environ["PATH"] = str(tools) + os.pathsep + (prior["PATH"] or "") + os.environ["LITHE_ACCEPTANCE_TOOL_HOME"] = str(tools.parent) + try: + yield + finally: + for key, value in prior.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def events_after(workspace: Path, offset: int) -> list[dict]: + path = workspace / ".lithe-acceptance/events.jsonl" + if not path.exists(): + if offset: + raise ValueError("fixture evidence disappeared after the action snapshot") + return [] + if path.is_symlink() or path.is_junction() or path.stat().st_size < offset: + raise ValueError("fixture evidence was replaced or truncated") + if offset < 0 or path.stat().st_size - offset > 2 * 1024 * 1024: + raise ValueError("fixture evidence exceeded its per-action read budget") + with path.open("rb") as stream: + stream.seek(offset) + lines = stream.read().split(b"\n")[:-1] + events = [json.loads(line) for line in lines if line] + if any(not isinstance(row, dict) or row.get("workspace") != str(workspace) for row in events): + raise ValueError("fixture evidence belongs to another workspace") + return events + + +def event_offset(workspace: Path) -> int: + path = workspace / ".lithe-acceptance/events.jsonl" + return path.stat().st_size if path.exists() else 0 + + +def fixture_command_matches(command: str, script: Path) -> bool: + expected = str(script.resolve()).replace("\\", "/") + normalized = command.replace("\\", "/") + return re.search(r'(?:^|\s)"?' + re.escape(expected) + r'"?(?=\s|$)', normalized, re.I) is not None + + +def matching_events(events: list[dict], review: dict) -> list[dict]: + if any(key not in review for key in IDENTITY): + raise ValueError("review is missing its native operation identity") + return [event for event in events if all(event.get(key) == review[key] for key in IDENTITY)] + + +def assert_ordered_terminal(events: list[dict], review: dict, terminal: str) -> list[dict]: + matching = matching_events(events, review) + terminals = [event for event in matching if event.get("type") == "terminal"] + if len(terminals) != 1 or terminals[0].get("terminal", {}).get("kind") != terminal or matching[-1] != terminals[0]: + raise AssertionError("expected exactly one final terminal for the complete native identity") + return matching + + +class FixtureGate: + """A workspace-owned fixture control file, never installed/package state.""" + def __init__(self, workspace: Path, action: str): + if action not in ("model", "task", "test", "dependencies"): + raise ValueError("invalid controlled fixture action") + self.directory = confined(workspace, ".lithe-acceptance") + self.token = uuid.uuid4().hex + self.control = self.directory / "control.json" + self.release = self.directory / f"release-{self.token}" + self.action = action + + def __enter__(self): + with self.control.open("x", encoding="utf-8") as stream: + json.dump({"holdAction": self.action, "token": self.token}, stream) + return self + + def __exit__(self, *_): + self.release.touch(exist_ok=False) + self.control.unlink(missing_ok=True) + + +# Read-only instrumentation: every original invoke, success and rejection is +# passed through unchanged. No command is replaced and no events are forged. +OBSERVER = r"""async () => { + if (window.__litheBuildAcceptanceObserver) throw new Error('observer already active'); + const internals = window.__TAURI_INTERNALS__, original = internals.invoke; + const state = { calls: [], events: [], dropped: 0, original }; + const push = (array, item) => { if (array.length < 8192) array.push(item); else state.dropped++; }; + const allowed = command => command.startsWith('declarative_plugins_build_') || command === 'write_file'; + internals.invoke = function(command, args, ...rest) { + if (!allowed(command)) return original.call(this, command, args, ...rest); + const record = {command, args, settled:false}; push(state.calls, record); + return original.call(this, command, args, ...rest).then(value => { + record.settled=true; record.value=value; return value; + }, error => {record.settled=true; record.error=String(error); throw error;}); + }; + const callback = internals.transformCallback(event => push(state.events, event.payload)); + const target = {kind:'Window',label:internals.metadata.currentWindow.label}; + let timer; + const listen = original.call(internals, 'plugin:event|listen', {event:'declarative-build-output',target,handler:callback}); + let eventId; + try { + eventId = await Promise.race([listen,new Promise((_,reject) => { + timer=setTimeout(() => reject(new Error('observer listen deadline')),10000); + })]); + } catch (error) { + internals.invoke=original; + void listen.then(eventId => original.call(internals,'plugin:event|unlisten',{event:'declarative-build-output',eventId})) + .finally(() => internals.unregisterCallback(callback)).catch(error => console.error('acceptance observer cleanup',error)); + throw error; + } finally { clearTimeout(timer); } + state.stop = async () => { + internals.invoke=original; + let timer; + try { + await Promise.race([original.call(internals,'plugin:event|unlisten',{event:'declarative-build-output',eventId}), + new Promise((_,reject) => {timer=setTimeout(() => reject(new Error('observer unlisten deadline')),10000);})]); + } finally { + clearTimeout(timer); internals.unregisterCallback(callback); + delete window.__litheBuildAcceptanceObserver; + } + }; + window.__litheBuildAcceptanceObserver=state; +}""" + + +class BuildAcceptance: + def __init__(self, driver, args, reporter, restart): + self.driver, self.args, self.reporter, self.restart = driver, args, reporter, restart + self.root = args.build_fixture_root.resolve() + self.plan = load_plan(self.root) + self.capabilities = source_capabilities() + self.evidence = args.artifact_root.resolve() / "build" + self.evidence.mkdir(parents=True, exist_ok=True) + self.page = None + self.variant = None + self.workspace = None + self.owned_handles = {} + self.package_before = None + self.native_workspace = None + + def snapshot(self): + return self.page.evaluate("() => { const s=window.__litheBuildAcceptanceObserver; return {calls:s.calls,events:s.events,dropped:s.dropped}; }") + + def native(self, command, arguments=None): + return self.driver.invoke(self.page, command, arguments) + + def catalog(self): + return next((row for row in self.native("declarative_plugins_state") if row["pluginId"] == self.variant["pluginId"] and row["source"] == "installed"), None) + + def details(self): + return self.native("declarative_plugins_details", {"pluginId": self.variant["pluginId"], "source": "installed"}) + + def region(self): + return self.page.get_by_role("region", name=self.variant["displayName"], exact=True) + + def open_build(self): + self.driver.close_settings(self.page) + region = self.region() + if not region.count() or not region.is_visible(): + self.page.get_by_role("button", name=self.variant["displayName"], exact=True).click(timeout=15_000) + region.wait_for(state="visible", timeout=15_000) + return region + + def choose(self, scope, label, option): + trigger = scope.get_by_role("combobox", name=label, exact=True) + trigger.click(timeout=10_000) + self.page.get_by_role("option", name=option, exact=True).click(timeout=10_000) + + def import_package(self, marker: str, *, cancel=False): + asset = self.variant["archives"][marker] + review = self.driver.import_review(self.page, self.session.process.pid, confined(self.root, asset["path"])) + text = review.inner_text(timeout=10_000) + if "Unverified publisher" not in text or "process.build" not in text: + raise AssertionError("the real import review omitted trust or process.build") + review.get_by_role("button", name=re.compile(r"Program declaration \(\d+\)")).click(timeout=10_000) + programs = json.loads(review.locator("pre").inner_text(timeout=10_000)) + if not any(program.get("kind") == "buildSystem" for program in programs): + raise AssertionError("native install review omitted the full build declaration") + if cancel: + self.driver.cancel_review(self.page, review) + else: + self.driver.confirm_review(self.page, review) + self.driver.wait_until(lambda: self.catalog() and self.catalog().get("status") == "accepted", 15, "native installed build fixture") + provenance = self.details()["versions"]["current"] + if provenance["archiveSha256"] != asset["sha256"]: + raise AssertionError("native install provenance differs from approved archive") + return programs + + def installed_package(self): + # Existing store layout, read only. Never create/edit installation.json. + root = self.args.app_local_data / "plugins" / self.variant["pluginId"] / "1.0.0" + if not root.is_dir(): + raise AssertionError("the native installation directory is missing") + return root + + def uninstall(self): + self.driver.open_plugin_settings(self.page) + row = self.page.locator("[data-testid='declarative-plugin-list'] " + f"[data-plugin-id='{self.variant['pluginId']}'][data-plugin-source='installed']") + row.get_by_role("button").click(timeout=10_000) + details = self.page.locator("[data-settings-content='declarative-plugins']").get_by_role( + "region", name=self.variant["displayName"], exact=True) + details.get_by_role("button", name="Uninstall", exact=True).click(timeout=10_000) + self.driver.wait_until(lambda: self.catalog() is None, 15, "real build fixture uninstall") + if (self.args.app_local_data / "plugins" / self.variant["pluginId"]).exists(): + raise AssertionError("native uninstall left the fixture package directory") + + def passive_view(self): + offset = len(self.snapshot()["calls"]) + self.open_build().get_by_role("button", name="Reload", exact=True).click(timeout=10_000) + view = self.prepared("declarative_plugins_build_refresh", offset) + rows = [row for row in view["instances"] if row["pluginID"] == self.variant["pluginId"] and row["buildSystemID"] == self.variant["buildSystemId"]] + if not rows: + raise AssertionError("native detection returned no exact fixture instance") + self.native_workspace = view["workspaceRoot"] + return view, next(row for row in rows if row["root"] == ".") + + def prepared(self, command, call_offset): + return self.driver.wait_until(lambda: next((row["value"] for row in self.snapshot()["calls"][call_offset:] + if row["command"] == command and row.get("settled") and row.get("value")), None), 15, command + " native review") + + def approve(self, title, label, prepare_command, trigger, *, decline=False): + from playwright.sync_api import expect + offset = len(self.snapshot()["calls"]) + before = event_offset(self.workspace) + trigger() + dialog = self.page.get_by_role("dialog", name=title, exact=True) + expect(dialog).to_be_visible(timeout=15_000) + review = self.prepared(prepare_command, offset) + for key, expected in (("pluginId", self.variant["pluginId"]), ("buildSystemId", self.variant["buildSystemId"]), + ("root", "."), ("workspaceRoot", str(self.workspace))): + if (key == "workspaceRoot" and Path(review.get(key, "")).resolve() != Path(expected).resolve()) or (key != "workspaceRoot" and review.get(key) != expected): + raise AssertionError("review belongs to another fixture/context") + expect(dialog).to_contain_text(str(review[key])) + if events_after(self.workspace, before): + raise AssertionError("a project executable/probe ran before approval") + if any(row["command"] in ("declarative_plugins_build_execute_run", "declarative_plugins_build_load_model", "declarative_plugins_build_load_dependencies") + for row in self.snapshot()["calls"][offset:]): + raise AssertionError("native execution was invoked before the review decision") + dialog.get_by_role("button", name="Decline" if decline else label, exact=True).click(timeout=10_000) + expect(dialog).to_be_hidden(timeout=15_000) + if decline: + self.driver.wait_until(lambda: any(row["command"] == "declarative_plugins_build_cancel_operation" and row.get("settled") for row in self.snapshot()["calls"][offset:]), 15, "declined native operation cleanup") + if events_after(self.workspace, before): + raise AssertionError("declining executed a fixture tool") + return review, offset + + def terminal(self, review, kind): + events = self.driver.wait_until(lambda: (lambda rows: rows if any(row.get("type") == "terminal" for row in rows) else None)( + matching_events(self.snapshot()["events"], review)), 30, "matching native terminal") + return assert_ordered_terminal(events, review, kind) + + def save_and_model(self): + # Exercise the canonical editor and Ctrl+S. An on-disk fixture write + # cannot stand in for the product's save-before-build sequence. + self.driver.open_fixture_file(self.page, "acceptance.build.txt") + editor = self.page.locator(".monaco-editor textarea.inputarea").last + editor.focus(timeout=10_000) + editor.press("Control+End", timeout=10_000) + editor.press("End", timeout=10_000) + editor.type(" accepted-through-editor", timeout=10_000) + editor.press("Control+s", timeout=10_000) + self.driver.wait_until(lambda: "accepted-through-editor" in (self.workspace / "acceptance.build.txt").read_text(encoding="utf-8"), 10, "the real saved editor buffer") + region = self.open_build() + if self.variant["id"].startswith("static"): + region.get_by_role("button", name="Reload", exact=True).click(timeout=10_000) + else: + model_offset = event_offset(self.workspace) + review, offset = self.approve("Review model loading", "Allow and load", "declarative_plugins_build_prepare_model", + lambda: region.get_by_role("button", name=re.compile(r"^(Load|Reload) model…$")).click(timeout=10_000)) + self.driver.wait_until(lambda: any(row["command"] == "declarative_plugins_build_load_model" and row.get("settled") and "error" not in row + for row in self.snapshot()["calls"][offset:]), 30, "native model loaded after approval") + if review["operationId"] not in [row["args"].get("operationId") for row in self.snapshot()["calls"][offset:] if row["command"] == "declarative_plugins_build_load_model"]: + raise AssertionError("model execution did not consume its reviewed identity") + if not any(row.get("action") == "model" and "accepted-through-editor" in (row.get("savedText") or "") + for row in events_after(self.workspace, model_offset)): + raise AssertionError("the approved model process did not observe the saved editor contents") + _, row = self.current_view() + if not row.get("model", {}).get("projects"): + raise AssertionError("the Core-decoded native model has no projects") + + def current_view(self): + # sync_check returns the existing model rather than discarding a + # command model with a whole-registry re-detection. + view = self.native("declarative_plugins_build_sync_check", {"workspaceRoot": self.native_workspace, + "pluginId": self.variant["pluginId"], "buildSystemId": self.variant["buildSystemId"], "root": "."}) + return view, next(row for row in view["instances"] if row["pluginID"] == self.variant["pluginId"] and row["buildSystemID"] == self.variant["buildSystemId"] and row["root"] == ".") + + def run_action(self, testing=False, decline=False): + region = self.open_build().get_by_role("region", name="Tasks and tests", exact=True) + self.choose(region, "Action", "Tests" if testing else "build / Build") + project = region.get_by_role("combobox", name="Project", exact=True) + if project.count(): + self.choose(region, "Project", "fixture-app (.)") + return self.approve("Review task / test execution", "Allow and execute", "declarative_plugins_build_prepare_run", + lambda: region.get_by_role("button", name="Prepare task / test…", exact=True).click(timeout=10_000), decline=decline) + + def setting_failure(self): + before = event_offset(self.workspace) + self.native("declarative_plugins_store_setting", {"pluginId": self.variant["pluginId"], "settingId": "toolPath", "scope": "global", + "workspacePath": str(self.workspace), "value": str(self.root / "missing tool.cmd")}) + try: + review, _ = self.run_action() + self.terminal(review, "failedToStart") + if events_after(self.workspace, before): + raise AssertionError("a configured failure fell back to the command candidate") + finally: + self.native("declarative_plugins_store_setting", {"pluginId": self.variant["pluginId"], "settingId": "toolPath", "scope": "global", + "workspacePath": str(self.workspace), "value": str(confined(self.root, f"tools with spaces/bin/{COMMAND}"))}) + + def task_test_dependencies(self): + before = event_offset(self.workspace) + review, _ = self.run_action() + self.terminal(review, "succeeded") + if not any(row.get("action") == "task" and "accepted-through-editor" in (row.get("savedText") or "") + for row in events_after(self.workspace, before)): + raise AssertionError("the approved task did not read the saved editor contents") + review, _ = self.run_action(testing=True) + rows = self.terminal(review, "failed") + if not any(row.get("type") == "testResults" and row.get("results", {}).get("summary", {}).get("failed") == 1 for row in rows): + raise AssertionError("native Core test decoding did not return the controlled failure") + if not any(row.get("type") == "diagnostics" and row.get("diagnostics") for row in rows): + raise AssertionError("native Core diagnostics decoding did not return a source location") + region = self.open_build().get_by_role("region", name="Dependencies", exact=True) + project = region.get_by_role("combobox") + project.click(timeout=10_000) + self.page.get_by_role("option", name="fixture-app", exact=True).click(timeout=10_000) + review, offset = self.approve("Review dependency loading", "Approve and load", "declarative_plugins_build_prepare_dependencies", + lambda: region.get_by_role("button", name="Load dependencies…", exact=True).click(timeout=10_000)) + self.terminal(review, "succeeded") + result = self.driver.wait_until(lambda: next((row["value"] for row in self.snapshot()["calls"][offset:] + if row["command"] == "declarative_plugins_build_load_dependencies" and row.get("settled") and row.get("value")), None), 30, "Core-decoded dependency result") + if not result.get("dependencies"): + raise AssertionError("native dependency tree is missing") + region.get_by_text(re.compile("fixture-dependency")).first.wait_for(state="visible", timeout=10_000) + + def capture_fixture_processes(self, offset): + rows = events_after(self.workspace, offset) + pids = {row["pid"] for row in rows if row.get("event") in ("started", "gated") and row.get("action") != "probe"} + records = self.driver.process_tree(self.session.root_owner.pid) + captured = {owner.pid: owner for owner in self.driver.capture_owned_tree(self.session.root_owner, records)} + by_pid = {record["ProcessId"]: record for record in records} + script = tool_script(self.root, self.variant) + for pid in pids: + record = by_pid.get(pid) + if pid not in captured or record is None or not fixture_command_matches(record.get("CommandLine", ""), script): + raise AssertionError("fixture evidence lacks a retained matching application-descendant handle") + self.owned_handles[pid] = captured[pid] + return [self.owned_handles[pid] for pid in pids] + + def cleanup_owned(self): + # Normal native cancellation must already have ended every retained + # fixture handle. A forced cleanup is a failure, never a passing case. + survivors = [owner for owner in self.owned_handles.values() if owner.running()] + errors = [] + for owner in survivors: + try: + if self.driver.owned_processes.get(owner.pid) is not owner: + raise RuntimeError("refusing cleanup through a replaced process ownership handle") + self.driver.terminate_process_tree(owner.pid, timeout_seconds=5) + except Exception as error: + errors.append(str(error)) + self.owned_handles.clear() + if survivors: + raise AssertionError(f"native build cleanup left owned handles {[owner.pid for owner in survivors]}; forced cleanup: {errors}") + + def gated_run(self, action): + before = event_offset(self.workspace) + review, _ = self.run_action() + self.driver.wait_until(lambda: any(row.get("event") == "gated" and not row.get("child") for row in events_after(self.workspace, before)), 10, "controlled executable gate") + self.driver.wait_until(lambda: any(row.get("child") and row.get("event") == "started" for row in events_after(self.workspace, before)), 10, "controlled child started") + handles = self.capture_fixture_processes(before) + action(review) + self.driver.wait_until(lambda: all(not owner.running() for owner in handles), 15, "owned build process tree revoked") + return review + + def cancel_tree(self): + with FixtureGate(self.workspace, "task"): + review = self.gated_run(lambda _: self.open_build().get_by_role("button", name="Cancel task / test", exact=True).click(timeout=10_000)) + self.terminal(review, "cancelled") + + def replace_late(self): + from playwright.sync_api import expect + before_replace = ordinary_tree(self.installed_package()) + if before_replace != self.package_before: + raise AssertionError("runtime work changed the installed source package before replacement") + with FixtureGate(self.workspace, "task"): + old = self.gated_run(lambda _: self.import_package("b")) + self.open_build() + if ordinary_tree(self.installed_package()) == before_replace: + raise AssertionError("same-version replacement did not publish new package bytes") + self.package_before = ordinary_tree(self.installed_package()) + # A stale native review must be refused by the real host, never replayed + # through the observer or represented by a fabricated late event. + try: + self.native("declarative_plugins_build_execute_run", {"operationId": old["operationId"]}) + except Exception: + pass + else: + raise AssertionError("the replaced package's old run was accepted") + self.save_and_model() + before = event_offset(self.workspace) + new, _ = self.run_action() + self.terminal(new, "succeeded") + output = self.open_build().get_by_role("region", name="Tasks and tests", exact=True).get_by_role("region", name="Build Output", exact=True) + expect(output).to_contain_text("[marker=b]", timeout=10_000) + expect(output).not_to_contain_text("[marker=a]", timeout=10_000) + rows = events_after(self.workspace, before) + if not any(row.get("marker") == "b" and row.get("action") == "task" for row in rows): + raise AssertionError("replacement did not execute the newly approved package declaration") + if matching_events(self.snapshot()["events"], old) == matching_events(self.snapshot()["events"], new): + raise AssertionError("replacement reused an old operation identity") + + def close_workspace(self): + with FixtureGate(self.workspace, "task"): + old = self.gated_run(lambda _: self.native("declarative_plugins_build_close_workspace", {"workspaceRoot": self.native_workspace})) + try: + self.native("declarative_plugins_build_prepare_run", {"workspaceRoot": self.native_workspace, + "pluginId": self.variant["pluginId"], "buildSystemId": self.variant["buildSystemId"], "root": ".", + "selection": {"kind": "task", "taskId": "build", "projectId": None, "targetId": None, + "modelTaskId": None, "suite": None, "case": None, "userArguments": []}}) + except Exception: + pass + else: + raise AssertionError("a closed workspace admitted a new operation") + if not matching_events(self.snapshot()["events"], old): + raise AssertionError("closed operation had no actual native event evidence") + # The parent subsequently requires actual graceful Quit and retains + # its existing WebView2/descendant kernel-handle checks. + + def execute_variant(self, variant): + self.variant = variant + self.workspace = confined(self.root, variant["workspace"]) + with tool_environment(self.root): + self.page, self.session = self.restart(self.workspace) + self.page.evaluate(OBSERVER) + resources_before = self.driver.hash_tree(self.args.run_image.resolve()) + try: + with self.reporter.step(f"{variant['id']} / install-review"): + self.import_package("a", cancel=True) + if self.catalog() is not None: + raise AssertionError("cancelled native installation left a catalog entry") + self.import_package("a") + if variant["candidate"] in ("setting", "executableSetting"): + self.native("declarative_plugins_store_setting", {"pluginId": variant["pluginId"], "settingId": "toolPath", "scope": "global", + "workspacePath": str(self.workspace), "value": str(confined(self.root, f"tools with spaces/bin/{COMMAND}"))}) + self.package_before = ordinary_tree(self.installed_package()) + with self.reporter.step(f"{variant['id']} / passive-detection"): + before = event_offset(self.workspace) + self.open_build(); self.passive_view() + if events_after(self.workspace, before): + raise AssertionError("passive detection probed or executed a project tool") + with self.reporter.step(f"{variant['id']} / saved-model"): + self.save_and_model() + with self.reporter.step(f"{variant['id']} / decline-run"): + self.run_action(decline=True) + with self.reporter.step(f"{variant['id']} / task-test-dependencies"): + self.task_test_dependencies() + if variant["candidate"] == "setting": + with self.reporter.step(SETTING_CASE): + self.setting_failure() + with self.reporter.step(f"{variant['id']} / cancel-tree"): + self.cancel_tree() + with self.reporter.step(f"{variant['id']} / replace-late"): + self.replace_late() + with self.reporter.step(f"{variant['id']} / close-workspace"): + self.close_workspace() + with self.reporter.step(f"{variant['id']} / read-only-resources"): + if self.driver.hash_tree(self.args.run_image.resolve()) != resources_before: + raise AssertionError("the packaged resource baseline changed") + if ordinary_tree(self.installed_package()) != self.package_before: + raise AssertionError("installed package files changed during runtime work") + with self.reporter.step(f"{variant['id']} / uninstall"): + self.uninstall() + finally: + try: + snapshot = self.snapshot() + self.driver.write_json(self.evidence / f"{variant['id']}.json", snapshot) + if snapshot["dropped"]: + raise AssertionError("acceptance observer truncated its evidence") + finally: + try: + self.page.evaluate("() => window.__litheBuildAcceptanceObserver.stop()") + finally: + self.cleanup_owned() + + def execute_official(self): + archive = self.args.build_official_maven_package.resolve(strict=True) + with zipfile.ZipFile(archive) as package: + manifest = json.loads(package.read("plugin.json")) + system = next((system for system in manifest.get("buildSystems", []) if system.get("hostIntegration") == "maven"), None) + if system is None: + raise ValueError("the supplied official archive declares no Maven hostIntegration") + if missing_capabilities(manifest.get("requires", {}), self.capabilities): + return + self.workspace = confined(self.root, self.plan["hostIntegration"]["workspace"]) + self.variant = {"pluginId": manifest["id"], "displayName": system.get("displayName", system["id"]), "buildSystemId": system["id"]} + self.page, self.session = self.restart(self.workspace) + self.page.evaluate(OBSERVER) + try: + with self.reporter.step(OFFICIAL): + review = self.driver.import_review(self.page, self.session.process.pid, archive) + self.driver.confirm_review(self.page, review) + details = self.driver.wait_until(lambda: self.details(), 15, "official installed package details") + if details["versions"]["current"]["verification"]["status"] != "publisherSignature": + raise AssertionError("hostIntegration acceptance requires a genuine trusted publisher signature") + package_root = self.args.app_local_data / "plugins" / manifest["id"] / manifest["version"] + before = ordinary_tree(package_root) + region = self.open_build() + _, instance = self.passive_view() + if not instance.get("model", {}).get("projects"): + raise AssertionError("passive Maven hostIntegration returned no Core model") + run = region.get_by_role("region", name="Tasks and tests", exact=True) + for option, terminal in (("lifecycle / compile", "succeeded"), ("Tests", "succeeded")): + self.choose(run, "Action", option) + # Both official Maven actions expose workspace scope. + scope = run.get_by_role("combobox", name="Scope", exact=True) + if scope.count(): + self.choose(run, "Scope", "Workspace") + native_review, _ = self.approve("Review task / test execution", "Allow and execute", "declarative_plugins_build_prepare_run", + lambda: run.get_by_role("button", name="Prepare task / test…", exact=True).click(timeout=10_000)) + self.terminal(native_review, terminal) + dependencies = region.get_by_role("region", name="Dependencies", exact=True) + native_review, offset = self.approve("Review dependency loading", "Approve and load", "declarative_plugins_build_prepare_dependencies", + lambda: dependencies.get_by_role("button", name="Load dependencies…", exact=True).click(timeout=10_000)) + self.terminal(native_review, "succeeded") + result = self.driver.wait_until(lambda: next((call["value"] for call in self.snapshot()["calls"][offset:] + if call["command"] == "declarative_plugins_build_load_dependencies" and call.get("settled") and call.get("value")), None), 30, "official native dependency decode") + if result.get("dependencies") is None: + raise AssertionError("official hostIntegration did not decode dependencies") + if ordinary_tree(package_root) != before: + raise AssertionError("official installed resources changed at runtime") + finally: + self.driver.write_json(self.evidence / "host-integration.json", self.snapshot()) + self.page.evaluate("() => window.__litheBuildAcceptanceObserver.stop()") + + def run(self): + try: + for variant in self.plan["variants"]: + if missing_capabilities(variant["requires"], self.capabilities): + continue + self.execute_variant(variant) + if self.args.build_official_maven_package: + self.execute_official() + finally: + record_unreached_cases(self.reporter, self.plan, self.capabilities, + official=bool(self.args.build_official_maven_package)) + + +def main(): + parser = argparse.ArgumentParser(description="Write pending build acceptance cases without launching a product or server.") + parser.add_argument("--fixture-root", required=True, type=Path) + parser.add_argument("--report", required=True, type=Path) + args = parser.parse_args() + report = {"runner": "acceptance", "warnMs": 30_000, "maxMs": 120_000, + "tests": planned_cases(load_plan(args.fixture_root), source_capabilities())} + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + print(f"Registered {len(report['tests'])} pending native build cases; no product executed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/build_acceptance_test.py b/scripts/plugin-host-acceptance/build_acceptance_test.py new file mode 100644 index 0000000000..afb698ca92 --- /dev/null +++ b/scripts/plugin-host-acceptance/build_acceptance_test.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +"""Bounded offline helper checks; these never count as native build acceptance.""" + +from __future__ import annotations + +import argparse +import ast +import contextlib +import hashlib +import io +import json +import os +import subprocess +import sys +import tempfile +import time +import unittest +import zipfile +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import build_acceptance as acceptance +import build_fixture as fixture + + +class BuildHelperTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="lithe-build-helper-") + self.addCleanup(temporary.cleanup) + self.directory = Path(temporary.name) + self.root = self.directory / "fixture" + self.plan = fixture.generate_fixture(self.root) + + def test_deterministic_archives_and_source_identity(self): + other = self.directory / "second" + second = fixture.generate_fixture(other) + self.assertEqual(self.plan, second) + for variant in self.plan["variants"]: + for marker, archive in variant["archives"].items(): + first, another = (root / archive["path"] for root in (self.root, other)) + self.assertEqual(first.read_bytes(), another.read_bytes()) + self.assertEqual(hashlib.sha256(first.read_bytes()).hexdigest(), archive["sha256"]) + with zipfile.ZipFile(first) as package: + manifest = json.loads(package.read("plugin.json")) + expected, source, _ = fixture.declaration(variant["id"], marker) + self.assertEqual(manifest, expected) + self.assertEqual(variant["sourceSha256"], hashlib.sha256((fixture.CONTRACTS / source).read_bytes()).hexdigest()) + self.assertEqual(manifest["vendor"]["signatureRequirement"], "none") + self.assertEqual(manifest["permissions"], ["process.build"]) + self.assertNotIn("lithe-plugin-signature.json", package.namelist()) + fixture.load_plan(self.root) + + def test_five_candidate_sources_and_legacy_adapter_are_inputs_only(self): + candidates = set() + for variant in self.plan["variants"]: + declaration, _, kind = fixture.declaration(variant["id"], "a") + candidates.add(kind) + system = declaration["buildSystems"][0] + self.assertNotIn("provider", system) + self.assertNotIn("buildProviders", declaration) + invocation = system["invocation"] + if kind == "executableSetting": + self.assertEqual(invocation["executableSetting"], "toolPath") + self.assertEqual(declaration["requires"]["buildSystems"], 2) + else: + self.assertEqual(invocation["executables"][0]["kind"], kind) + if kind == "setting": + self.assertEqual(invocation["executables"][1]["kind"], "command") + self.assertEqual(declaration["settings"][0]["scope"], "global") + self.assertEqual(declaration["requires"]["executableResolution"], 1) + if kind == "runtime": + self.assertEqual(invocation["runtimeDependencies"], [{"id": "node", "minimumVersion": "18.0.0"}]) + self.assertEqual(candidates, {"projectFile", "environment", "command", "runtime", "setting", "executableSetting"}) + + def test_existing_decoder_maps_and_outputfile_templates_remain_canonical(self): + for variant in self.plan["variants"]: + manifest, source, _ = fixture.declaration(variant["id"], "a") + original = json.loads((fixture.CONTRACTS / source).read_text(encoding="utf-8"))["buildSystems"][0] + system = manifest["buildSystems"][0] + self.assertEqual(system.get("model"), original.get("model")) + self.assertEqual(system.get("diagnostics"), original.get("diagnostics")) + self.assertEqual(system.get("sync"), original.get("sync")) + self.assertEqual(system.get("tasks"), original.get("tasks")) + if "dependencies" in original: + self.assertEqual(system["dependencies"], original["dependencies"]) + + def test_same_version_replacement_changes_reviewed_bytes(self): + for variant in self.plan["variants"]: + self.assertNotEqual(variant["archives"]["a"]["sha256"], variant["archives"]["b"]["sha256"]) + a, b = (fixture.declaration(variant["id"], marker)[0] for marker in ("a", "b")) + self.assertEqual(a["id"], b["id"]) + self.assertEqual(a["version"], b["version"]) + self.assertNotEqual(a["buildSystems"][0]["invocation"]["globalArguments"], b["buildSystems"][0]["invocation"]["globalArguments"]) + + def test_existing_directory_is_never_cleared_or_overwritten(self): + before = fixture.ordinary_tree(self.root) + with self.assertRaises(FileExistsError): + fixture.generate_fixture(self.root) + self.assertEqual(fixture.ordinary_tree(self.root), before) + + def test_path_traversal_absolute_drive_and_empty_values_are_refused(self): + for path in ("../escape", "/absolute", "C:/fixture", "C:relative", "x\\escape", "", "a/./b", "a//b"): + with self.subTest(path=path), self.assertRaises(ValueError): + fixture.confined(self.root, path) + self.assertEqual(fixture.confined(self.root, "packages").name, "packages") + + def test_archive_digest_and_manifest_changes_are_refused(self): + archive = self.root / self.plan["variants"][0]["archives"]["a"]["path"] + archive.write_bytes(archive.read_bytes() + b"changed") + with self.assertRaisesRegex(ValueError, "digest"): + fixture.load_plan(self.root) + + def test_manifest_tamper_is_not_blessed_by_changing_the_recorded_digest(self): + variant = self.plan["variants"][0] + asset = variant["archives"]["a"] + archive = self.root / asset["path"] + with zipfile.ZipFile(archive) as source: + files = {name: source.read(name) for name in source.namelist()} + document = json.loads(files["plugin.json"]) + document["permissions"].append("ui.web") + files["plugin.json"] = fixture.json_bytes(document) + archive.unlink() + fixture.write_archive(archive, files) + asset["sha256"] = hashlib.sha256(archive.read_bytes()).hexdigest() + (self.root / "plan.json").write_bytes(fixture.json_bytes(self.plan)) + with self.assertRaisesRegex(ValueError, "manifest"): + fixture.load_plan(self.root) + + def test_incomplete_archives_or_source_revision_are_refused(self): + variant = self.plan["variants"][0] + variant["archives"].pop("b") + (self.root / "plan.json").write_bytes(fixture.json_bytes(self.plan)) + with self.assertRaisesRegex(ValueError, "both"): + fixture.load_plan(self.root) + variant["sourceSha256"] = "0" * 64 + (self.root / "plan.json").write_bytes(fixture.json_bytes(self.plan)) + with self.assertRaisesRegex(ValueError, "canonical"): + fixture.load_plan(self.root) + + def test_host_integration_never_gets_an_unsigned_official_archive(self): + self.assertIn("official publisher-signed", self.plan["hostIntegration"]["prerequisite"]) + names = [path.name for path in (self.root / "packages").glob("*.zip")] + self.assertFalse(any("maven" in name or "host-integration" in name for name in names)) + self.assertTrue((self.root / "workspaces/host-integration/pom.xml").is_file()) + + def test_closed_capabilities_register_every_case_as_pending(self): + capabilities = {"languages": [1], "settings": [1, 2], "ui": [1, 2]} + rows = acceptance.planned_cases(self.plan, capabilities) + self.assertEqual(len(rows), len(fixture.VARIANTS) * len(acceptance.STEPS) + 2) + self.assertTrue(all(row["status"] == "skipped" and row["durationMs"] == 0 and "Not executed" in row["details"] for row in rows)) + self.assertIn("Closed source prerequisites", rows[0]["details"]) + + def test_source_capability_reader_fails_closed_on_nonliteral_implementation(self): + source = self.directory / "source/windows/tauri/src-tauri/src/plugin_host" + source.mkdir(parents=True) + (source / "mod.rs").write_text("pub fn host_capabilities() -> Value { from_config() }") + with self.assertRaises(ValueError): + acceptance.source_capabilities(self.directory / "source") + + def test_partial_capability_does_not_enable_settings_or_legacy(self): + capabilities = {"buildSystems": [1], "settings": [1, 2]} + legacy = next(variant for variant in self.plan["variants"] if variant["candidate"] == "executableSetting") + setting = next(variant for variant in self.plan["variants"] if variant["candidate"] == "setting") + self.assertEqual(acceptance.missing_capabilities(legacy["requires"], capabilities), ["buildSystems:2"]) + self.assertEqual(acceptance.missing_capabilities(setting["requires"], capabilities), ["executableResolution:1"]) + + def test_pending_registration_never_overwrites_a_real_failure(self): + failed = {"name": "static-project / install-review", "status": "failed", "details": "native failure"} + reporter = SimpleNamespace(tests=[failed]) + acceptance.record_unreached_cases(reporter, self.plan, {}) + acceptance.record_unreached_cases(reporter, self.plan, {}) + self.assertEqual(reporter.tests[0], failed) + self.assertEqual(len({row["name"] for row in reporter.tests}), len(reporter.tests)) + + def test_closed_build_lane_never_starts_product_or_calls_ipc(self): + args = SimpleNamespace(build_fixture_root=self.root, artifact_root=self.directory / "artifacts", + build_official_maven_package=None) + driver, restart = Mock(), Mock() + reporter = SimpleNamespace(tests=[]) + with patch.object(acceptance, "source_capabilities", return_value={}): + lane = acceptance.BuildAcceptance(driver, args, reporter, restart) + lane.run() + restart.assert_not_called() + driver.invoke.assert_not_called() + self.assertTrue(reporter.tests) + self.assertTrue(all(row["status"] == "skipped" for row in reporter.tests)) + + def test_arguments_require_fixture_root_for_official_package(self): + parser = argparse.ArgumentParser() + acceptance.add_arguments(parser) + plain = parser.parse_args([]) + acceptance.validate_arguments(parser, plain) + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + acceptance.validate_arguments(parser, parser.parse_args(["--build-official-maven-package", "official.zip"])) + + def test_tool_environment_uses_isolated_paths_with_spaces_and_always_restores(self): + keys = ("PATH", "LITHE_ACCEPTANCE_TOOL_HOME") + before = {key: os.environ.get(key) for key in keys} + with self.assertRaises(RuntimeError): + with acceptance.tool_environment(self.root): + self.assertEqual(os.environ["PATH"].split(os.pathsep)[0], str(self.root / "tools with spaces/bin")) + self.assertEqual(os.environ["LITHE_ACCEPTANCE_TOOL_HOME"], str(self.root / "tools with spaces")) + raise RuntimeError("controlled failure") + self.assertEqual({key: os.environ.get(key) for key in keys}, before) + + def test_gate_failure_releases_only_its_owned_workspace_control(self): + workspace = self.root / self.plan["variants"][0]["workspace"] + gate = acceptance.FixtureGate(workspace, "task") + with self.assertRaises(RuntimeError): + with gate: + data = json.loads(gate.control.read_text()) + self.assertEqual(data, {"holdAction": "task", "token": gate.token}) + raise RuntimeError("controlled failure") + self.assertFalse(gate.control.exists()) + self.assertTrue(gate.release.is_file()) + self.assertTrue((workspace / "Cargo.toml").is_file()) + with self.assertRaises(ValueError): + acceptance.FixtureGate(workspace, "arbitrary-program") + + def test_events_require_new_completed_records_and_exact_workspace(self): + workspace = (self.root / self.plan["variants"][0]["workspace"]).resolve() + path = workspace / ".lithe-acceptance/events.jsonl" + row = {"workspace": str(workspace), "event": "started", "pid": 12} + # Real logs are one compact JSON document per completed line. + path.write_text(json.dumps(row) + "\n", encoding="utf-8") + offset = acceptance.event_offset(workspace) + self.assertEqual(acceptance.events_after(workspace, offset), []) + with path.open("ab") as stream: + stream.write(json.dumps(row).encode()) + self.assertEqual(acceptance.events_after(workspace, offset), []) + with path.open("ab") as stream: + stream.write(b"\n") + self.assertEqual(acceptance.events_after(workspace, offset), [row]) + with path.open("ab") as stream: + stream.write((json.dumps({**row, "workspace": "unrelated"}) + "\n").encode()) + with self.assertRaises(ValueError): + acceptance.events_after(workspace, offset) + path.write_text("", encoding="utf-8") + with self.assertRaises(ValueError): + acceptance.events_after(workspace, offset) + + def test_all_six_native_identity_fields_isolate_late_events(self): + review = {"operationId": "operation-a", "pluginId": "fixture.plugin", "buildSystemId": "fixture", + "root": ".", "workspaceRoot": "C:/fixture", "generation": 1} + event = {**review, "type": "chunk", "stream": "stdout", "text": "one"} + self.assertEqual(acceptance.matching_events([event], review), [event]) + for key in acceptance.IDENTITY: + changed = {**event, key: 2 if key == "generation" else "foreign"} + self.assertEqual(acceptance.matching_events([changed], review), []) + del review["operationId"] + with self.assertRaises(ValueError): + acceptance.matching_events([event], review) + + def test_terminal_is_single_final_and_for_the_exact_operation(self): + review = dict(zip(acceptance.IDENTITY, ("op", "plugin", "system", ".", "C:/fixture", 1))) + chunk = {**review, "type": "chunk", "text": "log"} + terminal = {**review, "type": "terminal", "terminal": {"kind": "cancelled"}} + self.assertEqual(acceptance.assert_ordered_terminal([chunk, terminal], review, "cancelled"), [chunk, terminal]) + for events in ([terminal, chunk], [chunk], [terminal, terminal], [{**terminal, "operationId": "old"}]): + with self.assertRaises(AssertionError): + acceptance.assert_ordered_terminal(events, review, "cancelled") + + def test_installed_tree_snapshots_detect_added_removed_and_changed_bytes(self): + package = self.directory / "readonly-package" + package.mkdir() + (package / "resource").write_bytes(b"before") + before = fixture.ordinary_tree(package) + (package / "resource").write_bytes(b"after") + self.assertNotEqual(fixture.ordinary_tree(package), before) + (package / "resource").write_bytes(b"before") + self.assertEqual(fixture.ordinary_tree(package), before) + (package / "events.jsonl").write_text("runtime write") + self.assertNotEqual(fixture.ordinary_tree(package), before) + + def test_process_cleanup_never_terminates_a_pid_without_a_retained_owner(self): + driver = Mock() + dead, alive = Mock(pid=10), Mock(pid=20) + dead.running.return_value = False + alive.running.return_value = True + driver.owned_processes = {10: dead, 20: alive} + args = SimpleNamespace(build_fixture_root=self.root, artifact_root=self.directory / "evidence", build_official_maven_package=None) + lane = acceptance.BuildAcceptance(driver, args, SimpleNamespace(tests=[]), Mock()) + lane.owned_handles = {10: dead, 20: alive} + with self.assertRaisesRegex(AssertionError, "forced cleanup"): + lane.cleanup_owned() + driver.terminate_process_tree.assert_called_once_with(20, timeout_seconds=5) + self.assertEqual(lane.owned_handles, {}) + + def test_fixture_pid_record_requires_the_exact_workspace_script(self): + workspace = (self.root / self.plan["variants"][0]["workspace"]).resolve() + script = workspace / ".lithe-acceptance/fake-build-tool.mjs" + self.assertTrue(acceptance.fixture_command_matches(f'node "{script}" --fixture-child', script)) + for command in (f'node "{script}.other"', f'node "{script.parent}/not-fake-build-tool.mjs"', 'node unrelated.mjs'): + self.assertFalse(acceptance.fixture_command_matches(command, script)) + + def test_process_capture_matches_each_declared_tool_location(self): + driver = Mock() + args = SimpleNamespace(build_fixture_root=self.root, artifact_root=self.directory / "evidence", build_official_maven_package=None) + lane = acceptance.BuildAcceptance(driver, args, SimpleNamespace(tests=[]), Mock()) + lane.session = SimpleNamespace(root_owner=SimpleNamespace(pid=1)) + for variant in self.plan["variants"]: + with self.subTest(candidate=variant["candidate"]): + workspace = (self.root / variant["workspace"]).resolve() + local = workspace / fixture.TOOL_RELATIVE + external = self.root / "tools with spaces/bin" / fixture.TOOL.name + script, unrelated = ((local, external) if variant["candidate"] in ("projectFile", "runtime") + else (external, local)) + self.assertEqual(fixture.tool_script(self.root, variant), script) + owner = Mock(pid=12) + driver.process_tree.return_value = [{"ProcessId": 12, "CommandLine": f'node "{script}" --fixture-child'}] + driver.capture_owned_tree.return_value = [owner] + lane.variant, lane.workspace = variant, workspace + events = [{"event": "started", "pid": 12, "action": "task"}] + with patch.object(acceptance, "events_after", return_value=events): + self.assertEqual(lane.capture_fixture_processes(0), [owner]) + driver.process_tree.return_value[0]["CommandLine"] = f'node "{unrelated}" --fixture-child' + with self.assertRaisesRegex(AssertionError, "matching application-descendant"): + lane.capture_fixture_processes(0) + + def test_cleanup_refuses_a_reused_pid_and_keeps_actual_old_handle_authority(self): + old, replacement = Mock(pid=20), Mock(pid=20) + old.running.return_value = True + driver = Mock(owned_processes={20: replacement}) + args = SimpleNamespace(build_fixture_root=self.root, artifact_root=self.directory / "evidence", build_official_maven_package=None) + lane = acceptance.BuildAcceptance(driver, args, SimpleNamespace(tests=[]), Mock()) + lane.owned_handles = {20: old} + with self.assertRaisesRegex(AssertionError, "replaced process ownership"): + lane.cleanup_owned() + driver.terminate_process_tree.assert_not_called() + + def test_observer_only_transparently_forwards_original_ipc_and_targets_window(self): + observer = acceptance.OBSERVER + self.assertIn("original.call(this, command, args, ...rest)", observer) + self.assertIn("throw error", observer) + self.assertIn("target = {kind:'Window'", observer) + self.assertIn("internals.invoke=original", observer) + self.assertNotIn("plugin:event|emit", observer) + self.assertNotIn("Browser.close", observer) + + def test_new_driver_has_no_install_record_write_or_signature_tls_bypass(self): + source = Path(acceptance.__file__).read_text(encoding="utf-8") + tree = ast.parse(source) + imports = {node.module or "" for node in ast.walk(tree) if isinstance(node, ast.ImportFrom)} + self.assertNotIn("http.server", imports) + self.assertNotIn("ssl", imports) + for forbidden in ("ignore_https_errors", "check_hostname = False", "CERT_NONE", "Browser.close", "taskkill"): + self.assertNotIn(forbidden, source) + writes = [node for node in ast.walk(tree) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and node.func.attr in ("write_text", "write_bytes")] + self.assertEqual(len(writes), 1, "only the standalone pending report writer belongs here") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--report", type=Path) + parser.add_argument("--case") + arguments = parser.parse_args() + if arguments.case: + result = unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromName(arguments.case, sys.modules[__name__])) + return 0 if result.wasSuccessful() else 1 + if arguments.report is None: + parser.error("--report is required") + rows = [] + for name in unittest.defaultTestLoader.getTestCaseNames(BuildHelperTests): + started = time.monotonic() + status, details = "passed", "" + try: + result = subprocess.run([sys.executable, "-B", str(Path(__file__).resolve()), "--case", f"BuildHelperTests.{name}"], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10) + if result.returncode: + status, details = "failed", result.stdout + result.stderr + except subprocess.TimeoutExpired: + status, details = "timeout", "helper case exceeded its 10 second process deadline" + duration = round((time.monotonic() - started) * 1000, 3) + row = {"name": name, "suite": "plugin-host-acceptance / build-helpers", "status": status, + "durationMs": duration, "maxMs": 10_000, "details": details} + rows.append(row) + print(f"{status.upper()} {name} ({duration} ms)", flush=True) + if details: + print(details, file=sys.stderr) + arguments.report.parent.mkdir(parents=True, exist_ok=True) + arguments.report.write_text(json.dumps({"runner": "acceptance", "warnMs": 2_000, "maxMs": 10_000, "tests": rows}, indent=2) + "\n", encoding="utf-8") + return 1 if any(row["status"] != "passed" for row in rows) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/build_fixture.py b/scripts/plugin-host-acceptance/build_fixture.py new file mode 100644 index 0000000000..05607d41b2 --- /dev/null +++ b/scripts/plugin-host-acceptance/build_fixture.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Disposable non-provider build packages derived from the canonical fixtures. + +This module packages inputs, not a manifest validator or build interpreter. +Only the real install path/Core validates them. Generated community packages +are explicitly unsigned; hostIntegration keeps its official-signature prerequisite. +No input is installed, served, downloaded or executed by this generator. +""" + +from __future__ import annotations + +import argparse +import copy +import hashlib +import json +import re +import zipfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +CONTRACTS = ROOT / "shared/contracts/plugins/v1/fixtures" +TOOL = Path(__file__).with_name("fake-build-tool.mjs") +PREFIX = "io.github.lithe-acceptance.build" +VARIANTS = ( + ("static-project", "build-static-file.plugin.json", "projectFile"), + ("stdout-environment", "build-command-stdout.plugin.json", "environment"), + ("output-command", "build-command-output-file.plugin.json", "command"), + ("stdout-runtime", "build-command-stdout.plugin.json", "runtime"), + ("output-setting", "build-command-output-file.plugin.json", "setting"), + ("output-legacy", "build-command-output-file.plugin.json", "executableSetting"), +) +COMMAND = "lithe-build-acceptance.cmd" +TOOL_RELATIVE = ".lithe-acceptance/fake-build-tool.mjs" + +def json_bytes(value) -> bytes: + return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8") + + +def ordinary_tree(root: Path) -> dict[str, str]: + """Compare exact bytes and refuse links, including directory junctions.""" + if root.is_symlink() or root.is_junction(): + raise ValueError("acceptance resource root cannot be a link") + root = root.resolve(strict=True) + result = {} + directories = [root] + while directories: + for path in sorted(directories.pop().iterdir()): + if path.is_symlink() or path.is_junction(): + raise ValueError("acceptance resources must be ordinary files/directories") + if path.is_dir(): + directories.append(path) + elif path.is_file(): + if root not in path.resolve(strict=True).parents: + raise ValueError("acceptance resource escaped its root") + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + result[path.relative_to(root).as_posix()] = digest.hexdigest() + return result + + +def declaration(variant: str, marker: str) -> tuple[dict, str, str]: + _, source_name, candidate = next(item for item in VARIANTS if item[0] == variant) + source = json.loads((CONTRACTS / source_name).read_text(encoding="utf-8")) + manifest = copy.deepcopy(source) + manifest.update(id=f"{PREFIX}.{variant}", displayName=f"Lithe Build Acceptance {variant}", version="1.0.0") + manifest["vendor"] = {"id": "io.github.lithe-acceptance", "displayName": "Unsigned acceptance fixture", + "signatureRequirement": "none"} + system = manifest["buildSystems"][0] + system["displayName"] = manifest["displayName"] + # The source declaration still owns maps, filters, report locations and + # option semantics. Only deterministic test-tool discovery is substituted. + invocation = system["invocation"] + invocation.pop("runtimeDependencies", None) + invocation.pop("environmentInputs", None) + invocation["version"] = {"arguments": ["--version"], "pattern": r"Lithe Build Acceptance (\d+\.\d+\.\d+)"} + invocation["minimumVersion"] = "1.0.0" + executable = {"projectFile": {"kind": "projectFile", "path": f".lithe-acceptance/{COMMAND}"}, + "environment": {"kind": "environment", "variable": "LITHE_ACCEPTANCE_TOOL_HOME", "path": f"bin/{COMMAND}"}, + "command": {"kind": "command", "command": COMMAND}, + "runtime": {"kind": "runtime", "runtime": "node"}, + "setting": {"id": "selected", "kind": "setting", "settingID": "toolPath"}, + "executableSetting": {"kind": "command", "command": COMMAND}}[candidate] + invocation["executables"] = [executable] + invocation["globalArguments"] = ["--fixture-marker", marker, *invocation.get("globalArguments", [])] + reports = json.loads((CONTRACTS / "build-command-output-file.plugin.json").read_text(encoding="utf-8"))["buildSystems"][0]["test"]["results"] + system["test"]["results"] = copy.deepcopy(reports) + if "dependencies" not in system: + system["dependencies"] = copy.deepcopy(json.loads((CONTRACTS / "build-command-stdout.plugin.json").read_text(encoding="utf-8"))["buildSystems"][0]["dependencies"]) + if candidate == "environment": + invocation["environmentInputs"] = ["LITHE_ACCEPTANCE_TOOL_HOME"] + if candidate in ("setting", "executableSetting"): + manifest["requires"]["settings"] = 2 + manifest["settings"] = [{"id": "toolPath", "type": "file", "title": "Fixture tool", + "default": "", "scope": "global", "effect": "reload", + "page": {"buildSystemID": system["id"]}}] + # A following working command is a trap: configured failure must be terminal. + if candidate == "setting": + manifest["requires"]["executableResolution"] = 1 + invocation["executables"].append({"kind": "command", "command": COMMAND}) + else: + manifest["requires"]["buildSystems"] = 2 + invocation["executableSetting"] = "toolPath" + if candidate == "runtime": + invocation["runtimeDependencies"] = [{"id": "node", "minimumVersion": "18.0.0"}] + invocation["version"]["pattern"] = r"v(\d+\.\d+\.\d+)" + invocation["minimumVersion"] = "18.0.0" + invocation["globalArguments"] = [TOOL_RELATIVE, *invocation.get("globalArguments", [])] + return manifest, source_name, candidate + + +def write_archive(path: Path, files: dict[str, bytes]) -> None: + with zipfile.ZipFile(path, "x", compression=zipfile.ZIP_DEFLATED) as archive: + for name, data in sorted(files.items()): + item = zipfile.ZipInfo(name, (2026, 1, 1, 0, 0, 0)) + item.compress_type = zipfile.ZIP_DEFLATED + item.external_attr = 0o100644 << 16 + archive.writestr(item, data) + + +def generate_fixture(output: Path) -> dict: + # Disposable run inputs have no reusable cache identity. Never clear a + # previous run, share node_modules/target, or write an installed resource. + if output.is_symlink() or output.is_junction(): + raise ValueError("fixture output cannot be a link") + output.mkdir(parents=True, exist_ok=False) + packages = output / "packages" + packages.mkdir() + tools = output / "tools with spaces" / "bin" + tools.mkdir(parents=True) + tool_data = TOOL.read_bytes() + (tools / TOOL.name).write_bytes(tool_data) + variants = [] + for variant, _, _ in VARIANTS: + workspace = output / "workspaces" / variant + workspace.mkdir(parents=True) + (workspace / ".lithe-acceptance").mkdir() + (workspace / ".lithe-acceptance" / TOOL.name).write_bytes(tool_data) + (workspace / "nested").mkdir() + (workspace / "src").mkdir() + (workspace / "src/main.rs").write_text("fn main() {}\n", encoding="utf-8") + (workspace / "src/main.go").write_text("package main\nfunc main() {}\n", encoding="utf-8") + (workspace / "src/Main.java").write_text("class Main {}\n", encoding="utf-8") + (workspace / "acceptance.build.txt").write_text("saved-before-build\n", encoding="utf-8") + (workspace / "Cargo.toml").write_text('[package]\nname="fixture-app"\nversion="1.0.0"\n[features]\nweb=[]\n', encoding="utf-8") + (workspace / "go.mod").write_text("module fixture-app\n\ngo 1.21\n", encoding="utf-8") + (workspace / "build.gradle").write_text("// deterministic controlled acceptance input\n", encoding="utf-8") + (workspace / "settings.gradle").write_text("rootProject.name='fixture-app'\n", encoding="utf-8") + archives = {} + for marker in ("a", "b"): + manifest, source, candidate = declaration(variant, marker) + archive = packages / f"{variant}-{marker}.zip" + write_archive(archive, {"plugin.json": json_bytes(manifest), "fixture-marker.txt": (marker + "\n").encode(), + "lithe-model.init.gradle": b"// Fake tool consumes this declared input; never a Gradle implementation.\n"}) + archives[marker] = {"path": archive.relative_to(output).as_posix(), + "sha256": hashlib.sha256(archive.read_bytes()).hexdigest()} + wrapper = f'@echo off\r\nnode "%~dp0fake-build-tool.mjs" --fixture-marker a %*\r\n'.encode() + (workspace / ".lithe-acceptance" / COMMAND).write_bytes(wrapper) + variants.append({"id": variant, "pluginId": manifest["id"], "displayName": manifest["displayName"], + "buildSystemId": manifest["buildSystems"][0]["id"], "sourceFixture": source, + "sourceSha256": hashlib.sha256((CONTRACTS / source).read_bytes()).hexdigest(), + "candidate": candidate, "requires": manifest["requires"], + "workspace": workspace.relative_to(output).as_posix(), "archives": archives}) + (tools / COMMAND).write_bytes(f'@echo off\r\nnode "%~dp0fake-build-tool.mjs" --fixture-marker a %*\r\n'.encode()) + official = output / "workspaces" / "host-integration" + official.mkdir() + (official / "pom.xml").write_text('4.0.0' + 'fixtureacceptance1.0.0\n', encoding="utf-8") + plan = {"schemaVersion": 1, "variants": variants, "tools": "tools with spaces/bin", + "trust": "unsigned third-party disk import; real native review/approval required", + "hostIntegration": {"sourceFixture": "build-host-integration.plugin.json", "workspace": "workspaces/host-integration", + "prerequisite": "A genuine official publisher-signed Maven plugin archive and installed JDK/Maven; never generate or trust an acceptance key."}} + (output / "plan.json").write_bytes(json_bytes(plan)) + return plan + + +def load_plan(root: Path) -> dict: + plan = json.loads((root / "plan.json").read_text(encoding="utf-8")) + if (plan.get("schemaVersion") != 1 or len(plan.get("variants", [])) != len(VARIANTS) + or {v.get("id") for v in plan.get("variants", [])} != {v[0] for v in VARIANTS}): + raise ValueError("unsupported or incomplete build fixture plan") + for variant in plan["variants"]: + expected, source, candidate = declaration(variant["id"], "a") + if (variant["pluginId"] != expected["id"] or variant["requires"] != expected["requires"] + or variant["displayName"] != expected["displayName"] or variant["workspace"] != f"workspaces/{variant['id']}" + or variant["sourceFixture"] != source or variant["candidate"] != candidate + or variant["sourceSha256"] != hashlib.sha256((CONTRACTS / source).read_bytes()).hexdigest()): + raise ValueError("fixture plan no longer matches its canonical source") + if set(variant["archives"]) != {"a", "b"}: + raise ValueError("both same-version package snapshots are required") + for marker, asset in variant["archives"].items(): + path = confined(root, asset["path"]) + if marker not in ("a", "b") or hashlib.sha256(path.read_bytes()).hexdigest() != asset["sha256"]: + raise ValueError("fixture archive digest changed") + with zipfile.ZipFile(path) as archive: + if json.loads(archive.read("plugin.json")) != declaration(variant["id"], marker)[0]: + raise ValueError("fixture manifest changed") + ordinary_tree(root) + return plan + + +def tool_script(root: Path, variant: dict) -> Path: + # Project-file and runtime declarations use the workspace script; external + # candidates run the script beside their shared, generated cmd wrapper. + relative = (f"{variant['workspace']}/{TOOL_RELATIVE}" + if variant["candidate"] in ("projectFile", "runtime") + else f"tools with spaces/bin/{TOOL.name}") + return confined(root, relative) + + +def confined(root: Path, relative: str) -> Path: + if not isinstance(relative, str) or not relative or relative.startswith("/") or "\\" in relative or re.match(r"^[A-Za-z]:", relative): + raise ValueError("fixture paths must be relative POSIX paths") + path = Path(relative) + if path.is_absolute() or any(part in ("", ".", "..") for part in relative.split("/")): + raise ValueError("fixture paths cannot traverse") + target = root.resolve() / path + cursor = target + while cursor != root.resolve(): + if cursor.is_symlink() or cursor.is_junction(): + raise ValueError("fixture paths cannot contain links") + if cursor.parent == cursor: + raise ValueError("fixture path is not below its root") + cursor = cursor.parent + if root.resolve() not in target.resolve(strict=True).parents: + raise ValueError("fixture path escaped its root") + return target.resolve(strict=True) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", required=True, type=Path) + arguments = parser.parse_args() + print(json.dumps(generate_fixture(arguments.output), indent=2)) diff --git a/scripts/plugin-host-acceptance/build_preflight_test.py b/scripts/plugin-host-acceptance/build_preflight_test.py new file mode 100644 index 0000000000..b1e151af27 --- /dev/null +++ b/scripts/plugin-host-acceptance/build_preflight_test.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python3 +"""Offline build preparation checks; never invokes Cargo, Tauri or a product.""" +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +import shutil +import subprocess +import sys +import tempfile +import tarfile +import time +import zipfile +from pathlib import Path + +from acceptance_driver import OwnedProcess, process_exit_proven, run_bounded_process, wait_until +from acceptance_driver_test import run_case +from vcs_move_diagnostic import WindowsJob, supervised + +ROOT = Path(__file__).resolve().parents[2] + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--report", required=True, type=Path) + args = parser.parse_args() + tests = [] + process_cleanup = [] + archive_jobs = [] + scratch = None + deadline = time.monotonic() + 180 + try: + with tempfile.TemporaryDirectory(prefix="lithe-build-preflight-") as temporary: + root = Path(temporary) + scratch = root + scripts = root / "scripts" + scripts.mkdir() + app = root / "windows/tauri" + (app / "src-tauri").mkdir(parents=True) + (app / "src/extensions/bundled").mkdir(parents=True) + native = root / "native" + native.mkdir() + # Node is an offline native stand-in: it implements --version and + # can be found by spawnSync when named bun.exe. No Bun install runs. + shutil.copyfile(shutil.which("node"), native / "bun.exe") + version = run_bounded_process([str(native / "bun.exe"), "--version"], 5).stdout.strip() + package = app / "package.json" + package.write_text(json.dumps({"packageManager": "bun@" + version}), encoding="utf-8") + for name in ("windows-build-preflight.ps1", "build-windows.ps1", "install-windows-frontend-dependencies.ps1", "invoke-windows-tauri-build.ps1", "prepare-jdtls.ps1", "windows-tree-sitter-proxy.cjs"): + shutil.copyfile(ROOT / "scripts" / name, scripts / name) + powershell = shutil.which("pwsh") + + def jdtls_fixture(name, *, corrupt_archive=False): + fixture = root / name + fixture_scripts = fixture / "scripts" + fixture_scripts.mkdir(parents=True) + shutil.copyfile(scripts / "prepare-jdtls.ps1", fixture_scripts / "prepare-jdtls.ps1") + jdt = fixture / "third_party/jdtls" + jdt.mkdir(parents=True) + jdk = fixture / "third_party/jdk" + jdk.mkdir(parents=True) + (jdk / "manifest.json").write_text('{"version":"21.0.0"}', encoding="utf-8") + cache = fixture / ".artifacts/jdtls-downloads" + cache.mkdir(parents=True) + manifest = json.loads((ROOT / "third_party/jdtls/manifest.json").read_text(encoding="utf-8")) + # All payloads are tiny, locally generated fixture bytes. The + # real preparation script still checks every declared SHA. + manifest.update(version="1-fixture", lombokVersion="1", javaDebugExtensionVersion="1", + javaDebugServerVersion="1", javaTestExtensionVersion="1", javaTestPluginVersion="1") + payload = io.BytesIO() + with tarfile.open(fileobj=payload, mode="w:gz") as archive: + for filename in ("bin/jdtls.ps1", "bin/jdtls.bat", "plugins/org.eclipse.equinox.launcher_fixture.jar", + "config_mac/config.ini", "config_win/config.ini"): + data = b"offline fixture\n" + entry = tarfile.TarInfo(filename) + entry.size = len(data) + archive.addfile(entry, io.BytesIO(data)) + archive_bytes = b"not a gzip archive\n" if corrupt_archive else payload.getvalue() + archive_path = fixture / "absolute archive with spaces.tar.gz" + archive_path.write_bytes(archive_bytes) + manifest["archiveSHA256"] = hashlib.sha256(archive_bytes).hexdigest() + + def cached(field, filename, data): + digest = hashlib.sha256(data).hexdigest() + manifest[field + "SHA256"] = digest + manifest[field + "URL"] = "https://offline.invalid/not-requested" + (cache / filename.format(hash=digest)).write_bytes(data) + + cached("license", "EPL-2.0-{hash}.txt", b"fixture EPL") + cached("lombok", "lombok-1-{hash}.jar", b"fixture lombok") + cached("lombokLicense", "lombok-MIT-1-{hash}.txt", b"fixture lombok MIT") + debug_plugin = b"fixture debug plugin" + manifest["javaDebugPluginSHA256"] = hashlib.sha256(debug_plugin).hexdigest() + debug_zip = io.BytesIO() + with zipfile.ZipFile(debug_zip, "w") as archive: + archive.writestr("extension/server/com.microsoft.java.debug.plugin-1.jar", debug_plugin) + cached("javaDebugArchive", "vscode-java-debug-1-{hash}.zip", debug_zip.getvalue()) + cached("javaDebugLicense", "java-debug-EPL-1.0-1-{hash}.txt", b"fixture debug EPL") + test_plugin, test_runner = b"fixture test plugin", b"fixture test runner" + manifest["javaTestPluginSHA256"] = hashlib.sha256(test_plugin).hexdigest() + manifest["javaTestRunnerSHA256"] = hashlib.sha256(test_runner).hexdigest() + test_zip = io.BytesIO() + with zipfile.ZipFile(test_zip, "w") as archive: + archive.writestr("extension/package.json", json.dumps({"contributes": {"javaExtensions": ["./server/com.microsoft.java.test.plugin-1.jar"]}})) + archive.writestr("extension/server/com.microsoft.java.test.plugin-1.jar", test_plugin) + archive.writestr("extension/server/com.microsoft.java.test.runner-jar-with-dependencies.jar", test_runner) + cached("javaTestArchive", "vscode-java-test-1-{hash}.zip", test_zip.getvalue()) + cached("javaTestLicense", "java-test-MIT-1-{hash}.txt", b"fixture test MIT") + (jdt / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + fake_tar = fixture / "fake GNU tar" + fake_tar.mkdir() + # An executable named tar.exe at the front of PATH must never + # be launched. Node without its normal arguments rejects -xzf. + shutil.copyfile(native / "bun.exe", fake_tar / "tar.exe") + return fixture + + def jdtls_job(fixture, code, expected=0): + remaining = min(25, deadline-time.monotonic()) + assert remaining > 0, "preflight suite deadline exhausted" + script = fixture / "case.ps1" + script.write_text("""$ErrorActionPreference='Stop' +Set-Location $PSScriptRoot +$env:LITHE_JDTLS_ROOT='' +$env:LITHE_JDTLS_ARCHIVE=Join-Path $PSScriptRoot 'absolute archive with spaces.tar.gz' +$env:PATH=(Join-Path $PSScriptRoot 'fake GNU tar')+[IO.Path]::PathSeparator+$env:PATH +function global:Invoke-WebRequest { throw 'offline fixture attempted a download' } +""" + code, encoding="utf-8") + log = fixture / "job.log" + row = supervised([powershell, "-NoProfile", "-File", str(script)], remaining, log, WindowsJob) + archive_jobs.append({"fixture": fixture.name, **row}) + output = log.read_text(encoding="utf-8", errors="replace") + assert row.get("cleanup", {}).get("jobTerminated") is True and row["cleanup"]["activeProcesses"] == 0, row + assert row.get("exitCode") == expected and row.get("status") == ("passed" if expected == 0 else "failed"), output + return output + + def native_tar_ignores_path_and_extracts_absolute_space_paths(): + fixture = jdtls_fixture("JDT success with spaces") + output = jdtls_job(fixture, """ +$fake=Join-Path $PSScriptRoot 'fake GNU tar/tar.exe' +if((Get-Command tar.exe).Source -ne $fake){throw 'fixture did not prepend fake GNU tar'} +& ./scripts/prepare-jdtls.ps1 -OutputDirectory (Join-Path $PSScriptRoot '.artifacts/JDT output with spaces') +""") + target = fixture / ".artifacts/JDT output with spaces" + assert (target / "config_win/config.ini").read_bytes() == b"offline fixture\n", output + assert "-javaagent:" in (target / "bin/jdtls.ps1").read_text() + assert (target / "java-test/extensions.txt").read_text().strip() == "com.microsoft.java.test.plugin-1.jar" + assert str(target) in output + + def failed_native_tar_preserves_error_and_stops_copy(): + fixture = jdtls_fixture("JDT corrupt with spaces", corrupt_archive=True) + output = jdtls_job(fixture, """ +& ./scripts/prepare-jdtls.ps1 -OutputDirectory (Join-Path $PSScriptRoot '.artifacts/JDT output with spaces') +""", expected=1) + target = fixture / ".artifacts/JDT output with spaces" + assert "JDTLS archive extraction failed (native tar exit" in output, output + # PowerShell may wrap the archive path at spaces in its error + # rendering; the native diagnostic and script failure stay intact. + assert "absolute archive with" in output and "spaces.tar.gz" in output and "tar.exe:" in output, output + for filename in ("LICENSE-EPL-2.0.txt", "manifest.json", "lombok/lombok.jar", "bin/jdtls.ps1"): + assert not (target / filename).exists(), filename + + def missing_native_tar_fails_before_mutating_cache_or_output(): + fixture = jdtls_fixture("JDT missing native tar") + shutil.rmtree(fixture / ".artifacts/jdtls-downloads") + target = fixture / ".artifacts/JDT output with spaces" + target.mkdir() + (target / "keep.txt").write_bytes(b"existing output") + output = jdtls_job(fixture, """ +function global:Test-Path { + param([string]$LiteralPath,[string]$PathType) + if($LiteralPath -eq (Join-Path ([Environment]::SystemDirectory) 'tar.exe')){return $false} + Microsoft.PowerShell.Management\\Test-Path @PSBoundParameters +} +& ./scripts/prepare-jdtls.ps1 -OutputDirectory (Join-Path $PSScriptRoot '.artifacts/JDT output with spaces') +""", expected=1) + assert "Native Windows tar was not found:" in output, output + assert not (fixture / ".artifacts/jdtls-downloads").exists() + assert (target / "keep.txt").read_bytes() == b"existing output" + + def ps(code, expected=0): + remaining = min(25, deadline - time.monotonic()) + assert remaining > 0, "preflight suite deadline exhausted" + script = root / "case.ps1" + script.write_text("$ErrorActionPreference='Stop'\nSet-Location $PSScriptRoot\n" + code, encoding="utf-8") + result = run_bounded_process([powershell, "-NoProfile", "-File", str(script)], remaining) + assert result.returncode == expected, result.stdout + result.stderr + return result.stdout + + initialize = ". ./scripts/windows-build-preflight.ps1\nInitialize-WindowsNativeBun -RepositoryRoot $PSScriptRoot -NativeBunPath ./native/bun.exe\n" + + def native_path_precedes_node_verifier(): + output = ps(initialize + "& node -e \"const r=require('child_process').spawnSync('bun',['--version'],{encoding:'utf8'}); if(r.error||r.status!==0)process.exit(1); console.log(r.stdout.trim())\"\nif($LASTEXITCODE -ne 0){throw 'spawn failed'}") + assert version in output + + def npm_shim_resolves_native_without_executing_shim(): + shim = root / "shim" + packaged = shim / "node_modules/bun/bin" + packaged.mkdir(parents=True) + tools = root / "node-only" + tools.mkdir() + os.link(native / "bun.exe", tools / "node.exe") + # This hardlink is confined to the disposable fixture. It is + # never a link to another worktree or a mutable build cache. + os.link(native / "bun.exe", packaged / "bun.exe") + (shim / "bun.ps1").write_text("throw 'shim must not execute'\n", encoding="utf-8") + output = ps(""" +. ./scripts/windows-build-preflight.ps1 +$env:PATH=(Join-Path $PSScriptRoot 'shim')+[IO.Path]::PathSeparator+(Join-Path $PSScriptRoot 'node-only') +Initialize-WindowsNativeBun -RepositoryRoot $PSScriptRoot +& node -e "const r=require('child_process').spawnSync('bun',['--version'],{encoding:'utf8'});if(r.error||r.status)process.exit(1);console.log(r.stdout.trim())" +if($LASTEXITCODE -ne 0){throw 'spawn failed'} +""") + assert version in output + + def wrong_version_and_missing_native_fail(): + try: + package.write_text('{"packageManager":"bun@0.0.0-fixture"}', encoding="utf-8") + ps(initialize, expected=1) + finally: + package.write_text(json.dumps({"packageManager": "bun@" + version}), encoding="utf-8") + ps(initialize.replace("./native/bun.exe", "./missing/bun.exe"), expected=1) + + manifest = app / "src-tauri/Cargo.toml" + original = b'[package]\r\nname = "fixture"\r\n# \xe4\xb8\xad\xe6\x96\x87\r\n' + + wrapper = ". ./scripts/windows-build-preflight.ps1\n$manifest=Join-Path $PSScriptRoot 'windows/tauri/src-tauri/Cargo.toml'\n" + + def unchanged_manifest_is_not_rewritten(): + manifest.write_bytes(original) + output = ps(wrapper + """ +$stamp=[datetime]::new(2001,2,3,4,5,6,[DateTimeKind]::Utc) +[IO.File]::SetLastWriteTimeUtc($manifest,$stamp) +Invoke-TauriWithOriginalManifestBytes $manifest { } +if([IO.File]::GetLastWriteTimeUtc($manifest) -ne $stamp){throw 'unchanged manifest was rewritten'} +'unchanged' +""") + assert "unchanged" in output and manifest.read_bytes() == original + + def tauri_admitted_conversion_restores_on_success_and_failure(): + manifest.write_bytes(original) + ps(wrapper + "Invoke-TauriWithOriginalManifestBytes $manifest { $b=[IO.File]::ReadAllText($manifest).Replace(\"`r`n\",\"`n\"); [IO.File]::WriteAllText($manifest,$b) }") + assert manifest.read_bytes() == original + # Failure after an admitted conversion keeps the first error. + output = ps(wrapper + "try { Invoke-TauriWithOriginalManifestBytes $manifest { [IO.File]::WriteAllText($manifest,[IO.File]::ReadAllText($manifest).Replace(\"`r`n\",\"`n\")); throw 'first-build-error' } } catch { if($_ -notmatch 'first-build-error'){throw}; 'first-preserved' }") + assert "first-preserved" in output and manifest.read_bytes() == original + + def semantic_manifest_edit_is_preserved_and_both_errors_reported(): + for action_failure in (False, True): + manifest.write_bytes(original) + action = "[IO.File]::WriteAllText($manifest,'semantic-change');" + ("throw 'first-build-error'" if action_failure else "") + verify = "if($_ -notmatch 'beyond.*admitted'){throw};" + if action_failure: + verify += "if($_.Exception -isnot [AggregateException] -or $_ -notmatch 'first-build-error'){throw};" + output = ps(wrapper + f"try {{ Invoke-TauriWithOriginalManifestBytes $manifest {{ {action} }}; throw 'unexpected success' }} catch {{ {verify} 'preserved' }}") + assert "preserved" in output and manifest.read_bytes() == b"semantic-change" + + def locked_manifest_is_preserved_without_racing_a_read_and_write(): + manifest.write_bytes(original) + output = ps(wrapper + """ +$held=$null +try { + try { + Invoke-TauriWithOriginalManifestBytes $manifest { + [IO.File]::WriteAllText($manifest,[IO.File]::ReadAllText($manifest).Replace("`r`n","`n")) + $script:held=[IO.File]::Open($manifest,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + throw 'first-build-error' + } + throw 'unexpected success' + } catch { + if($_.Exception -isnot [AggregateException] -or $_ -notmatch 'first-build-error'){throw} + if($_.Exception.InnerExceptions[0].InnerException -isnot [IO.IOException]){throw} + 'locked-preserved' + } +} finally { if($held){$held.Dispose()} } +""") + assert "locked-preserved" in output and manifest.read_bytes() == original.replace(b"\r\n", b"\n") + + def removed_manifest_is_not_recreated_and_keeps_action_failure(): + manifest.write_bytes(original) + output = ps(wrapper + """ +try { + Invoke-TauriWithOriginalManifestBytes $manifest { Remove-Item -LiteralPath $manifest; throw 'first-build-error' } + throw 'unexpected success' +} catch { + if($_.Exception -isnot [AggregateException] -or $_ -notmatch 'first-build-error'){throw} + 'removed-preserved' +} +if(Test-Path -LiteralPath $manifest){throw 'deleted manifest was recreated'} +""") + assert "removed-preserved" in output and not manifest.exists() + + def real_tauri_owner_wraps_both_attempts(): + manifest.write_bytes(original) + output = ps(""" +$env:LITHE_CARGO_BUILD_CACHE_RESTORED='true' +$global:attempt=0 +function global:bunx { + $global:attempt++ + $manifest=Join-Path $PSScriptRoot 'windows/tauri/src-tauri/Cargo.toml' + [IO.File]::WriteAllText($manifest,[IO.File]::ReadAllText($manifest).Replace("`r`n","`n")) + $global:LASTEXITCODE=if($global:attempt -eq 1){1}else{0} +} +& ./scripts/invoke-windows-tauri-build.ps1 -TauriArguments @('fake-only') +if($global:attempt -ne 2){throw 'retry not reached'} +'two-attempts' +""" + ) + assert "two-attempts" in output and manifest.read_bytes() == original + + def installer_budget_reaches_each_owned_worker(): + (scripts / "invoke-windows-bun-install.ps1").write_text(""" +param([switch]$NoCache,[int]$TimeoutSeconds) +@{timeout=$TimeoutSeconds;retry=[bool]$NoCache} | ConvertTo-Json -Compress | Add-Content (Join-Path $PSScriptRoot '../attempts.jsonl') +if(-not $NoCache){exit 1} +exit 0 +""", encoding="utf-8") + (scripts / "verify-download-cache.mjs").write_text("process.exit(0);\n", encoding="utf-8") + for argument, budget in (("", 300), ("-InstallTimeoutSeconds 741", 741)): + attempts = root / "attempts.jsonl" + attempts.unlink(missing_ok=True) + ps(initialize + f"& ./scripts/install-windows-frontend-dependencies.ps1 {argument}") + records = [json.loads(line) for line in attempts.read_text(encoding="utf-8-sig").splitlines()] + assert records == [{"timeout": budget, "retry": False}, {"timeout": budget, "retry": True}] + + def real_install_job_deadline_reaps_fake_bun_tree(): + worker = (ROOT / "scripts/invoke-windows-bun-install.ps1").read_text(encoding="utf-8-sig") + # Keep the actual existing Job/deadline owner. Substitute only + # the Bun command with an offline fixture that waits for IPC. + worker = worker.replace('$ErrorActionPreference = "Stop"', '$ErrorActionPreference = "Stop"\nfunction bun { & node (Join-Path $PSScriptRoot "../fake-bun.cjs") }', 1) + worker_path = scripts / "owned-worker.ps1" + worker_path.write_text(worker, encoding="utf-8") + marker = root / "ready.json" + (root / "fake-bun.cjs").write_text(""" +const {spawn}=require('child_process'),fs=require('fs'),path=require('path'); +const child=spawn(process.execPath,['-e',"process.send('ready');process.stdin.resume()"],{stdio:['pipe','ignore','ignore','ipc']}); +child.on('message',()=>fs.writeFileSync(path.join(__dirname,'ready.json'),JSON.stringify([process.pid,child.pid]))); +process.stdin.resume(); +""", encoding="utf-8") + owners = [] + process = subprocess.Popen([powershell, "-NoProfile", "-File", str(worker_path), "-TimeoutSeconds", "3"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + wait_until(lambda: marker.exists() or process.poll() is not None, min(10, deadline-time.monotonic()), "fake Bun IPC readiness") + assert marker.exists(), "worker exited before fake Bun readiness" + for pid in json.loads(marker.read_text(encoding="utf-8")): + owner = OwnedProcess.capture(pid) + assert owner is not None, "fixture exited before handle retention" + owners.append(owner) + # Keep stdin open until the real worker's deadline fires. + process.wait(timeout=8) + stdout, stderr = process.communicate(timeout=2) + assert process.returncode == 124, repr((stdout, stderr)) + assert b"exceeded its 3 second deadline" in stderr + wait_until(lambda: all(not owner.running() for owner in owners), 5, "Job-owned fixture exit") + finally: + if process.poll() is None: + process.kill() # Popen retains this exact worker handle. + process.wait(timeout=5) # Job close releases descendants. + try: + for owner in owners: + owner.terminate() + wait_until(lambda: all(not owner.running() for owner in owners), 5, "retained fixture cleanup") + assert all(process_exit_proven(owner.pid) for owner in owners) + process_cleanup.extend({"pid": owner.pid, "creationTime": owner.creation_time, + "signaled": not owner.running()} for owner in owners) + process_cleanup.append({"pid": process.pid, "owner": "Popen retained kernel handle", + "exitCode": process.returncode, "exited": process.poll() is not None}) + finally: + for owner in owners: + owner.close() + for stream in (process.stdin, process.stdout, process.stderr): + stream.close() + + def build_budget_reaches_installer(): + for name in ("prepare-jdtls.ps1", "prepare-jdk.ps1", "prepare-conpty.ps1"): + (scripts / name).write_text("Write-Output $PSScriptRoot\n", encoding="utf-8") + (scripts / "install-windows-frontend-dependencies.ps1").write_text("param([int]$InstallTimeoutSeconds)\n$InstallTimeoutSeconds | Set-Content (Join-Path $PSScriptRoot '../build-budget.txt')\n", encoding="utf-8") + ps(""" +function global:rustup { $global:LASTEXITCODE=0 } +function global:bun { $global:LASTEXITCODE=1 } +try { & ./scripts/build-windows.ps1 -NativeBunPath ./native/bun.exe -InstallTimeoutSeconds 741; throw 'unexpected build success' } +catch { if($_ -notmatch 'frontend type check failed'){throw} } +""" + ) + assert (root / "build-budget.txt").read_text(encoding="utf-8-sig").strip() == "741" + + def acceptance_wires_budget_and_orders_native_before_reuse(): + # Parse the actual acceptance call (without executing any + # formal build/guards) and execute it against a recording stub. + entry = ROOT / "scripts/test-windows-declarative-plugin-host.ps1" + code = r""" +$tokens=$null; $errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile($args[0],[ref]$tokens,[ref]$errors) +if($errors.Count){throw $errors} +$call=$ast.Find({param($n) $n -is [Management.Automation.Language.CommandAst] -and $n.Extent.Text -match '^& .*scripts/build-windows.ps1'},$true) +if(-not $call){throw 'build call missing'} +$root=$PSScriptRoot; $RustTarget='x86_64-pc-windows-msvc'; $InstallTimeoutSeconds=741 +Invoke-Expression $call.Extent.Text +""" + (scripts / "build-windows.ps1").write_text("param($Configuration,$RustTarget,$InstallTimeoutSeconds)\nif($InstallTimeoutSeconds -ne 741){throw 'acceptance lost budget'}\n", encoding="utf-8") + # Literal paths are data, passed as script arguments. + script = root / "entry-check.ps1" + script.write_text(code, encoding="utf-8") + result = run_bounded_process([powershell, "-NoProfile", "-File", str(script), str(entry)], min(10, deadline-time.monotonic())) + assert result.returncode == 0, result.stdout + result.stderr + source = entry.read_text(encoding="utf-8") + assert source.index("Initialize-WindowsNativeBun -RepositoryRoot") < source.index('"scripts/reuse-worktree-resources.mjs"') + + for name, case in ( + ("native Windows tar ignores PATH and extracts absolute drive paths with spaces", native_tar_ignores_path_and_extracts_absolute_space_paths), + ("native tar extraction failure preserves its diagnostic and prevents later copies", failed_native_tar_preserves_error_and_stops_copy), + ("missing native tar fails before cache or output mutation", missing_native_tar_fails_before_mutating_cache_or_output), + ("native Bun is inherited by Node spawnSync before cache verification", native_path_precedes_node_verifier), + ("npm PowerShell shim resolves adjacent native Bun without running shim", npm_shim_resolves_native_without_executing_shim), + ("wrong Bun version and missing native executable fail closed", wrong_version_and_missing_native_fail), + ("unchanged manifest bytes and write timestamp remain untouched", unchanged_manifest_is_not_rewritten), + ("exact CRLF to LF conversion restores bytes after successful and failed actions", tauri_admitted_conversion_restores_on_success_and_failure), + ("semantic manifest edits survive rejection and preserve the action error", semantic_manifest_edit_is_preserved_and_both_errors_reported), + ("concurrent manifest reader prevents exclusive restoration without changing bytes", locked_manifest_is_preserved_without_racing_a_read_and_write), + ("removed manifest is not recreated and the action error remains visible", removed_manifest_is_not_recreated_and_keeps_action_failure), + ("existing Tauri retry owner restores original manifest bytes", real_tauri_owner_wraps_both_attempts), + ("installer preserves default and explicit per-attempt budgets through retry", installer_budget_reaches_each_owned_worker), + ("existing install Job deadline reaps offline fake Bun and IPC child", real_install_job_deadline_reaps_fake_bun_tree), + ("real build entry passes its explicit budget to installer", build_budget_reaches_installer), + ("acceptance build call passes budget and initializes native Bun before reuse", acceptance_wires_budget_and_orders_native_before_reuse), + ): + run_case(name, case, tests, max_ms=30_000) + finally: + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps({"runner": "acceptance", "warnMs": 5000, "maxMs": 30_000, "tests": tests, + "cleanup": {"scratchPath": str(scratch), "scratchExists": bool(scratch and scratch.exists()), + "ownedProcesses": process_cleanup, "archiveJobs": archive_jobs}}, indent=2) + "\n", encoding="utf-8") + return int(any(test["status"] != "passed" for test in tests)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/build_tool_test.mjs b/scripts/plugin-host-acceptance/build_tool_test.mjs new file mode 100644 index 0000000000..67a44e3774 --- /dev/null +++ b/scripts/plugin-host-acceptance/build_tool_test.mjs @@ -0,0 +1,110 @@ +// Offline executable protocol checks. These never launch Lithe or a server. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const tool = fileURLToPath(new URL("./fake-build-tool.mjs", import.meta.url)); + +function withWorkspace(action) { + const parent = mkdtempSync(path.join(os.tmpdir(), "lithe-build-protocol-")); + const workspace = path.join(parent, "workspace with spaces"); + mkdirSync(workspace); + writeFileSync(path.join(workspace, "acceptance.build.txt"), "saved-buffer-from-editor\n"); + try { action(workspace, parent); } + finally { rmSync(parent, { recursive: true }); } +} + +function run(workspace, args) { + // Every non-gated fixture invocation owns one bounded child. No test starts + // the hold mode or its descendant; those belong to future native job tests. + const result = spawnSync(process.execPath, [tool, ...args], { + cwd: workspace, timeout: 5_000, maxBuffer: 1024 * 1024, + encoding: "utf8", windowsHide: true, + }); + assert.ifError(result.error); + return result; +} + +function events(workspace) { + return readFileSync(path.join(workspace, ".lithe-acceptance/events.jsonl"), "utf8") + .trim().split("\n").map(line => JSON.parse(line)); +} + +test("probe reports a stable version without launching a descendant", () => withWorkspace(workspace => { + const result = run(workspace, ["--version"]); + assert.equal(result.status, 0); + assert.equal(result.stdout, "Lithe Build Acceptance 1.0.0\n"); + assert.deepEqual(events(workspace).map(event => event.event), ["started", "finished"]); + assert.equal(events(workspace)[0].action, "probe"); +})); + +test("stdout model is input for the unchanged Core jsonStream map", () => withWorkspace(workspace => { + const result = run(workspace, ["list", "-json", "./..."]); + assert.equal(result.status, 0); + const document = JSON.parse(result.stdout); + assert.equal(document.Module.Path, "fixture-app"); + assert.equal(document.Module.Dir, workspace); + assert.equal(events(workspace)[0].savedText, "saved-buffer-from-editor\n"); +})); + +test("output-file model writes only the native-shaped operation destination", () => withWorkspace((workspace, parent) => { + const state = path.join(parent, "plugin-state", "fixture.plugin"); + mkdirSync(state, { recursive: true }); + const output = path.join(state, "build-unique.model.json"); + const result = run(workspace, ["litheModel", `-PlitheModelOutput=${output}`]); + assert.equal(result.status, 0); + assert.equal(result.stdout, ""); + const document = JSON.parse(readFileSync(output, "utf8")); + assert.equal(document.format, "litheBuildModel"); + assert.equal(document.version, 1); + assert.equal(document.projects[1].path, "nested"); + assert.ok(events(workspace).some(event => event.output === output)); +})); + +test("relative and non-state output destinations are rejected without creating them", () => withWorkspace((workspace, parent) => { + for (const output of ["relative.json", path.join(parent, "installed-resource.json")]) { + const result = run(workspace, ["litheModel", `-PlitheModelOutput=${output}`]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /exclusive plugin-state output file/); + } + assert.deepEqual(readdirSync(parent), ["workspace with spaces"]); +})); + +test("dependency stdout and output-file retain canonical decoder inputs", () => withWorkspace((workspace, parent) => { + const stdout = run(workspace, ["list", "-m", "-json", "all"]); + assert.equal(JSON.parse(stdout.stdout).Path, "fixture-dependency"); + const state = path.join(parent, "plugin-state", "fixture.plugin"); + mkdirSync(state, { recursive: true }); + const output = path.join(state, "build-unique.dependencies.json"); + const file = run(workspace, ["litheDependencies", `-PlitheDependenciesOutput=${output}`]); + assert.equal(file.status, 0); + assert.equal(JSON.parse(readFileSync(output, "utf8")).format, "litheDependencies"); +})); + +test("task preserves literal arguments and the last reviewed replacement marker", () => withWorkspace(workspace => { + const literal = "argument with spaces and $() characters"; + const result = run(workspace, ["--fixture-marker", "a", "--fixture-marker", "b", "build", literal]); + assert.equal(result.status, 0); + assert.equal(result.stdout, "controlled task stdout [marker=b]\n"); + assert.equal(result.stderr, "controlled task stderr\n"); + const started = events(workspace)[0]; + assert.equal(started.marker, "b"); + assert.equal(started.argv.at(-1), literal); +})); + +test("controlled test emits JUnit input, diagnostics, stderr and a failing exit", () => withWorkspace(workspace => { + const result = run(workspace, ["test"]); + assert.equal(result.status, 1); + assert.match(result.stdout, /src\/main\.rs:1:1: error:/); + assert.match(result.stdout, /src\/main\.go:1:1:/); + assert.match(result.stdout, /src\/Main\.java:1: error:/); + assert.match(result.stderr, /controlled test stderr/); + const report = readFileSync(path.join(workspace, "build/test-results/test/TEST-fixture.xml"), "utf8"); + assert.match(report, /failures="1"/); + assert.match(report, /file="src\/main.rs" line="1"/); + assert.equal(events(workspace).at(-1).exitCode, 1); +})); diff --git a/scripts/plugin-host-acceptance/fake-build-tool.mjs b/scripts/plugin-host-acceptance/fake-build-tool.mjs new file mode 100644 index 0000000000..a84ddf0596 --- /dev/null +++ b/scripts/plugin-host-acceptance/fake-build-tool.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +// Controlled executable, not a build parser/server. Core interprets all model, +// task, diagnostic, test and dependency semantics. Every hold has a local bound. +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const args = process.argv.slice(2); +const root = process.cwd(); +const state = path.join(root, ".lithe-acceptance"); +const controlFile = path.join(state, "control.json"); +const markerIndex = args.lastIndexOf("--fixture-marker"); +const marker = markerIndex < 0 ? "a" : args[markerIndex + 1]; +const child = args.includes("--fixture-child"); +mkdirSync(state, { recursive: true }); +function event(type, extra = {}) { + appendFileSync(path.join(state, "events.jsonl"), JSON.stringify({ + event: type, marker, pid: process.pid, parentPid: process.ppid, child, + argv: args, workspace: root, ...extra, + }) + "\n"); +} +let control = {}; +if (existsSync(controlFile)) control = JSON.parse(readFileSync(controlFile, "utf8")); +const probe = args.includes("--version"); +const model = args.includes("litheModel") || (args.includes("list") && !args.includes("-m")); +const dependencies = args.some((arg) => arg.endsWith("litheDependencies")) || args.includes("-m"); +const testing = args.some((arg) => arg === "test" || arg.endsWith(":test")); +const action = probe ? "probe" : model ? "model" : dependencies ? "dependencies" : testing ? "test" : "task"; +event("started", { action, savedText: existsSync(path.join(root, "acceptance.build.txt")) + ? readFileSync(path.join(root, "acceptance.build.txt"), "utf8") : null }); + +function emitOutput(value, prefix) { + const target = args.find((arg) => arg.startsWith(prefix)); + const text = JSON.stringify(value); + if (!target) { process.stdout.write(text + "\n"); return; } + const output = path.resolve(target.slice(prefix.length)); + // Native allocates the path. Fixtures must never create package/bundle state. + if (!path.isAbsolute(target.slice(prefix.length)) || !output.toLowerCase().includes(`${path.sep}plugin-state${path.sep}`)) { + throw new Error("expected the native operation's exclusive plugin-state output file"); + } + writeFileSync(output, text + "\n"); + event("output-file", { output }); +} + +function finish() { + if (probe) process.stdout.write("Lithe Build Acceptance 1.0.0\n"); + else if (model) { + if (args.some((arg) => arg.startsWith("-PlitheModelOutput="))) { + emitOutput({ format: "litheBuildModel", version: 1, + projects: [{ path: ".", name: "fixture-app", properties: { taskPrefix: "" } }, + { path: "nested", name: "fixture-nested", properties: { taskPrefix: ":nested:" } }], + targets: [], tasks: [], optionValues: {}, run: [], contentRoots: [], + }, "-PlitheModelOutput="); + } else process.stdout.write(JSON.stringify({ Module: { Path: "fixture-app", Dir: root, GoVersion: "1.21" }, + Name: "main", ImportPath: "fixture-app", Dir: root, TestGoFiles: ["fixture_test.go"] }) + "\n"); + } else if (dependencies) { + if (args.some((arg) => arg.startsWith("-PlitheDependenciesOutput="))) { + emitOutput({ format: "litheDependencies", version: 1, dependencies: [{ name: "fixture-dependency", version: "1.0.0", children: [] }] }, "-PlitheDependenciesOutput="); + } else process.stdout.write(JSON.stringify({ Path: "fixture-dependency", Version: "v1.0.0", Indirect: false }) + "\n"); + } else if (testing) { + const reports = path.join(root, "build/test-results/test"); + mkdirSync(reports, { recursive: true }); + writeFileSync(path.join(reports, "TEST-fixture.xml"), + 'fixture assertion\n'); + process.stdout.write("src/main.rs:1:1: error: controlled diagnostic\n"); + process.stdout.write("src/main.go:1:1: controlled diagnostic\n"); + process.stdout.write("src/Main.java:1: error: controlled diagnostic\n"); + process.stderr.write("controlled test stderr\n"); + process.exitCode = 1; + } else { + process.stdout.write(`controlled task stdout [marker=${marker}]\n`); + process.stderr.write("controlled task stderr\n"); + } + event("finished", { action, exitCode: process.exitCode ?? 0 }); +} + +let watcher = null; +let deadline = null; +let descendant = null; +function release() { + if (watcher) clearInterval(watcher); + if (deadline) clearTimeout(deadline); + watcher = null; deadline = null; + if (descendant && descendant.exitCode === null) descendant.kill(); +} +process.once("exit", release); +if (child || (!probe && control.holdAction === action)) { + if (!child) { + descendant = spawn(process.execPath, [fileURLToPath(import.meta.url), "--fixture-child", "--fixture-marker", marker], + { cwd: root, stdio: "ignore", windowsHide: true }); + event("child-spawned", { childPid: descendant.pid, action }); + } + event("gated", { action, token: control.token ?? null }); + const expires = performance.now() + 45_000; + // This is a real executable boundary; the acceptance driver controls a file + // gate, and both this watchdog and the host operation deadline remain active. + watcher = setInterval(() => { + if (!child && existsSync(path.join(state, `release-${control.token}`))) { + release(); finish(); + } else if (performance.now() >= expires) { + release(); process.exitCode = 73; event("gate-timeout", { action }); + } + }, 25); + deadline = setTimeout(() => { release(); process.exitCode = 73; }, 46_000); +} else finish(); diff --git a/scripts/plugin-host-acceptance/fake-lsp-server.mjs b/scripts/plugin-host-acceptance/fake-lsp-server.mjs new file mode 100644 index 0000000000..7fb31fa5dd --- /dev/null +++ b/scripts/plugin-host-acceptance/fake-lsp-server.mjs @@ -0,0 +1,188 @@ +#!/usr/bin/env node + +import { appendFileSync, existsSync, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import path from "node:path"; + +const argv = process.argv.slice(2); + +function argument(name, fallback = "") { + const index = argv.indexOf(name); + return index >= 0 && index + 1 < argv.length ? argv[index + 1] : fallback; +} + +const stateDirectory = path.resolve(argument("--state-dir", process.cwd())); +const fixtureVersion = argument("--fixture-version", "unknown"); +const childMode = argv.includes("--child"); +mkdirSync(stateDirectory, { recursive: true }); + +function writeEvidence(kind, extra = {}) { + const evidence = { + kind, + pid: process.pid, + parentPid: process.ppid, + fixtureVersion, + timestamp: new Date().toISOString(), + ...extra, + }; + writeFileSync( + path.join(stateDirectory, `${kind}-${fixtureVersion}-${process.pid}.json`), + `${JSON.stringify(evidence, null, 2)}\n`, + "utf8", + ); +} + +function logEvent(event, extra = {}) { + appendFileSync( + path.join(stateDirectory, "events.jsonl"), + `${JSON.stringify({ event, pid: process.pid, fixtureVersion, ...extra })}\n`, + "utf8", + ); +} + +if (childMode) { + writeEvidence("child-active"); + logEvent("child-started"); + const interval = setInterval(() => {}, 60_000); + const stop = (signal) => { + clearInterval(interval); + writeEvidence("child-stopped", { signal }); + process.exit(0); + }; + process.on("SIGINT", () => stop("SIGINT")); + process.on("SIGTERM", () => stop("SIGTERM")); +} else { + writeEvidence("server-active"); + logEvent("server-started", { argv }); + + const child = spawn( + process.execPath, + [ + fileURLToPath(import.meta.url), + "--child", + "--state-dir", + stateDirectory, + "--fixture-version", + fixtureVersion, + ], + { stdio: "ignore", windowsHide: true }, + ); + child.unref(); + writeEvidence("child-owned", { childPid: child.pid }); + + let buffer = Buffer.alloc(0); + let shutdownRequested = false; + let releaseInitialize = null; + + function send(message) { + const body = Buffer.from(JSON.stringify(message), "utf8"); + process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`); + process.stdout.write(body); + } + + function handle(message) { + logEvent("message", { + method: message.method ?? null, + id: message.id ?? null, + uri: message.params?.textDocument?.uri ?? null, + documentVersion: message.params?.textDocument?.version ?? null, + }); + if (message.method === "initialize") { + const respond = () => send({ + jsonrpc: "2.0", + id: message.id, + result: { + capabilities: { + textDocumentSync: 1, + hoverProvider: false, + }, + serverInfo: { name: "lithe-plugin-host-acceptance", version: fixtureVersion }, + }, + }); + // This optional gate is disposable control state in app-data, never in + // the signed/installed package. A real filesystem notification controls + // the race; the deadline only fails a forgotten release. + const gatePath = path.join(stateDirectory, "lsp-initialize-gate.json"); + let gate; + try { gate = JSON.parse(readFileSync(gatePath, "utf8")); } catch { gate = null; } + if (gate?.schemaVersion === 1 && /^[a-f0-9]{32}$/.test(gate.token)) { + const token = gate.token; + const releasePath = path.join(stateDirectory, `lsp-initialize-release-${token}`); + if (!existsSync(releasePath)) { + const timer = setTimeout(() => finish(4, "initialize-gate-deadline"), 15_000); + const watcher = watch(stateDirectory, () => { + if (releaseInitialize && existsSync(releasePath)) { + releaseInitialize?.(); + logEvent("initialize-released", { token }); + respond(); + } + }); + watcher.on("error", () => finish(4, "initialize-gate-watch-error")); + releaseInitialize = () => { + clearTimeout(timer); + watcher.close(); + releaseInitialize = null; + }; + logEvent("initialize-gated", { token }); + // Close the watch-registration race against an early release. + if (existsSync(releasePath)) { + releaseInitialize(); + logEvent("initialize-released", { token }); + respond(); + } + return; + } + } + respond(); + return; + } + if (message.method === "shutdown") { + shutdownRequested = true; + send({ jsonrpc: "2.0", id: message.id, result: null }); + return; + } + if (message.method === "exit") { + finish(shutdownRequested ? 0 : 1, "lsp-exit"); + return; + } + if (message.id !== undefined) { + send({ jsonrpc: "2.0", id: message.id, result: null }); + } + } + + function drain() { + while (true) { + const separator = buffer.indexOf("\r\n\r\n"); + if (separator < 0) return; + const header = buffer.subarray(0, separator).toString("ascii"); + const match = /(?:^|\r\n)Content-Length:\s*(\d+)/i.exec(header); + if (!match) finish(2, "malformed-header"); + const length = Number.parseInt(match[1], 10); + const frameEnd = separator + 4 + length; + if (buffer.length < frameEnd) return; + const body = buffer.subarray(separator + 4, frameEnd).toString("utf8"); + buffer = buffer.subarray(frameEnd); + handle(JSON.parse(body)); + } + } + + let finished = false; + function finish(code, reason) { + if (finished) return; + finished = true; + releaseInitialize?.(); + writeEvidence("server-stopped", { code, reason, childPid: child.pid }); + logEvent("server-stopped", { code, reason, childPid: child.pid }); + process.exit(code); + } + + process.stdin.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + drain(); + }); + process.stdin.on("end", () => finish(0, "stdin-end")); + process.stdin.on("error", (error) => finish(3, `stdin-error:${error.message}`)); + process.on("SIGINT", () => finish(0, "SIGINT")); + process.on("SIGTERM", () => finish(0, "SIGTERM")); +} diff --git a/scripts/plugin-host-acceptance/generate_packages.py b/scripts/plugin-host-acceptance/generate_packages.py new file mode 100644 index 0000000000..541df1bdd5 --- /dev/null +++ b/scripts/plugin-host-acceptance/generate_packages.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Generate deterministic community disk packages for native host acceptance.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import zipfile +from pathlib import Path + + +PLUGIN_ID = "io.github.lithe-acceptance.host" +DISPLAY_NAME = "Lithe Host Acceptance" + + +def manifest(version: str, fixture_version: str) -> dict: + return { + "schemaVersion": 2, + "id": PLUGIN_ID, + "displayName": DISPLAY_NAME, + "version": version, + "pluginAPIVersion": 2, + "vendor": { + "id": "io.github.lithe-acceptance", + "displayName": "Lithe acceptance fixture", + "signatureRequirement": "none", + }, + "permissions": ["languageServer.launch"], + "activationEvents": ["onLanguage:litheaccept"], + "languages": [ + { + "id": "litheaccept", + "displayName": "Lithe Acceptance", + "fileExtensions": ["litheaccept"], + "projectFileNames": ["acceptance.litheaccept"], + } + ], + "languageServers": [ + { + "languageID": "litheaccept", + "languageIdentifier": "litheaccept", + "launch": { + "kind": "runtime", + "tool": "node", + "entry": "server/fake-lsp-server.mjs", + }, + "arguments": [ + "--stdio", + "--state-dir", + "${workspaceStateDir}", + "--fixture-version", + fixture_version, + ], + } + ], + } + + +def write_package( + output_root: Path, + server_source: Path, + package_name: str, + version: str, + fixture_version: str, +) -> Path: + package_root = output_root / package_name + if package_root.exists(): + shutil.rmtree(package_root) + (package_root / "server").mkdir(parents=True) + (package_root / "plugin.json").write_text( + json.dumps(manifest(version, fixture_version), indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + (package_root / "fixture-version.txt").write_text(f"{fixture_version}\n", encoding="utf-8") + shutil.copy2(server_source, package_root / "server" / server_source.name) + + archive = output_root / f"{package_name}.zip" + if archive.exists(): + archive.unlink() + with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as package_zip: + for source in sorted(path for path in package_root.rglob("*") if path.is_file()): + relative = source.relative_to(package_root).as_posix() + info = zipfile.ZipInfo(relative, date_time=(2026, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + package_zip.writestr(info, source.read_bytes()) + return archive + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--server", required=True, type=Path) + args = parser.parse_args() + output = args.output.resolve() + server = args.server.resolve() + if not server.is_file(): + raise SystemExit(f"Fake language server is missing: {server}") + output.mkdir(parents=True, exist_ok=True) + packages = { + "v1_a": write_package(output, server, "host-acceptance-1.0.0-a", "1.0.0", "1.0.0-a"), + "v1_b": write_package(output, server, "host-acceptance-1.0.0-b", "1.0.0", "1.0.0-b"), + "v2": write_package(output, server, "host-acceptance-1.1.0", "1.1.0", "1.1.0"), + } + print(json.dumps({key: str(value) for key, value in packages.items()}, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/lsp_lifecycle_acceptance.py b/scripts/plugin-host-acceptance/lsp_lifecycle_acceptance.py new file mode 100644 index 0000000000..39498c145c --- /dev/null +++ b/scripts/plugin-host-acceptance/lsp_lifecycle_acceptance.py @@ -0,0 +1,209 @@ +"""Optional real-editor LSP lifecycle checks for the native acceptance driver. + +Events are evidence only after an action's byte offsets, for the exact document +and package marker, and from a currently live fixture whose native identity the +driver retained. PID counts alone never establish editor attachment. +""" + +from __future__ import annotations + +import json +import os +import re +import urllib.parse +import urllib.request +import uuid +from pathlib import Path + +from playwright.sync_api import expect + + +REOPEN_CASE = "LSP existing editor closes and immediately reopens three times" +GATE_CASE = "LSP initialize gate survives closing and reopening the same editor" +RETAINED_CASES = tuple(f"LSP retained editor reattaches after {action}" for action in + ("disable and enable", "same-version replacement", "update", "rollback")) +CASES = (REOPEN_CASE, GATE_CASE, *RETAINED_CASES) + + +def scoped_event_files(state_root: Path): + root = state_root.resolve() + if not root.exists(): + return [] + result = [] + for candidate in root.rglob("events.jsonl"): + if candidate.is_symlink() or not candidate.is_file(): + raise ValueError("a fixture event log must be an ordinary owned file") + actual = candidate.resolve() + if root not in actual.parents: + raise ValueError("a fixture event log escaped its plugin state root") + result.append(actual) + return sorted(result) + + +def event_offsets(state_root: Path) -> dict[str, int]: + root = state_root.resolve() + return {str(path.relative_to(root)): path.stat().st_size for path in scoped_event_files(root)} + + +def events_after(state_root: Path, offsets: dict[str, int]): + root = state_root.resolve() + result = [] + for path in scoped_event_files(root): + offset = offsets.get(str(path.relative_to(root)), 0) + if offset < 0 or path.stat().st_size < offset: + raise ValueError("a fixture event log was truncated after its action snapshot") + with path.open("rb") as stream: + stream.seek(offset) + data = stream.read() + # A concurrent append may expose a partial final line. Only completed + # JSON records count; malformed completed records remain a hard error. + for line in data.split(b"\n")[:-1]: + if line: + event = json.loads(line.decode("utf-8")) + if not isinstance(event, dict): + raise ValueError("a fixture event must be an object") + result.append({**event, "stateDirectory": str(path.parent)}) + return result + + +def canonical_file_uri(uri: str) -> str | None: + value = urllib.parse.urlsplit(uri) + if value.scheme != "file" or value.netloc not in ("", "localhost") or value.query or value.fragment: + return None + return os.path.normcase(str(Path(urllib.request.url2pathname(value.path)).resolve())) + + +def matching_did_open(events, marker: str, uri: str, live: set[int], identity_live): + expected = canonical_file_uri(uri) + if expected is None: + raise ValueError("the acceptance target must be a local file URI") + for event in events: + pid = event.get("pid") + if (event.get("event") == "message" and event.get("method") == "textDocument/didOpen" + and event.get("fixtureVersion") == marker + and isinstance(event.get("uri"), str) + and canonical_file_uri(event["uri"]) == expected + and isinstance(event.get("documentVersion"), int) + and event["documentVersion"] >= 0 + and pid in live and identity_live(pid)): + return event + return None + + +class InitializeGate: + """One bounded server gate in writable per-workspace plugin state.""" + + def __init__(self, state_directory: Path, state_root: Path): + self.directory = state_directory.resolve() + if state_root.resolve() not in self.directory.parents: + raise ValueError("initialize gates must stay below this plugin's state root") + if not self.directory.is_dir() or self.directory.is_symlink(): + raise ValueError("initialize gates require an existing ordinary workspace state directory") + self.token = uuid.uuid4().hex + self.path = self.directory / "lsp-initialize-gate.json" + self.release_path = self.directory / f"lsp-initialize-release-{self.token}" + + def __enter__(self): + with self.path.open("x", encoding="utf-8") as stream: + json.dump({"schemaVersion": 1, "token": self.token}, stream) + return self + + def release(self): + self.release_path.touch(exist_ok=True) + + def __exit__(self, *_): + # Release first on failures so no live fixture can retain a watcher. + self.release() + self.path.unlink(missing_ok=True) + # The release marker stays until plugin-state teardown. A late reader + # that already consumed the config must still see its release token. + + +class LspLifecycleAcceptance: + def __init__(self, driver, args, reporter, owned_pids: set[int]): + self.driver = driver + self.args = args + self.reporter = reporter + self.owned_pids = owned_pids + self.state_root = args.app_local_data / "plugin-state" / driver.PLUGIN_ID + self.target = args.workspace_one.resolve() / "acceptance.litheaccept" + self.uri = self.target.as_uri() + self.evidence = args.artifact_root.resolve() / "lsp-lifecycle" + self.evidence.mkdir(parents=True, exist_ok=True) + self.last_open = None + + def snapshot(self): + return event_offsets(self.state_root) + + def require_did_open(self, offsets, marker: str, name: str): + def ready(): + live = self.driver.live_fixture_pids(self.state_root) + self.owned_pids.update(live) + event = matching_did_open(events_after(self.state_root, offsets), marker, self.uri, live, + lambda pid: self.driver.fixture_processes[pid].running()) + return event + event = self.driver.wait_until(ready, 30, f"a fresh owned didOpen for {name}") + self.last_open = event + self.driver.write_json(self.evidence / (re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + ".json"), + {"offsets": offsets, "expectedURI": self.uri, "expectedMarker": marker, "didOpen": event}) + return event + + def select_existing(self, page): + self.driver.close_settings(page) + tab = page.get_by_role("tab", name=re.compile(r"^acceptance\.litheaccept")) + expect(tab).to_have_count(1, timeout=15_000) + tab.click() + expect(tab).to_have_attribute("aria-selected", "true", timeout=15_000) + + def close_reopen(self, page): + self.select_existing(page) + tab = page.get_by_role("tab", name=re.compile(r"^acceptance\.litheaccept")) + tab.hover() + tab.get_by_role("button").click() + expect(tab).to_have_count(0, timeout=15_000) + self.driver.open_fixture_file(page) + + def repeat_reopen(self, page, marker: str): + with self.reporter.step(REOPEN_CASE): + for iteration in range(3): + offsets = self.snapshot() + self.close_reopen(page) + self.require_did_open(offsets, marker, f"close-reopen-{iteration + 1}") + + def retained(self, page, marker: str, action: str, offsets): + with self.reporter.step(f"LSP retained editor reattaches after {action}"): + self.select_existing(page) + self.require_did_open(offsets, marker, action) + + def initialize_close_reopen(self, page, marker: str): + if self.last_open is None: + raise AssertionError("a verified prior didOpen is required to scope the initialize gate") + with self.reporter.step(GATE_CASE): + directory = Path(self.last_open["stateDirectory"]) + with InitializeGate(directory, self.state_root) as gate: + self.driver.open_plugin_settings(page) + self.driver.set_plugin_enabled(page, False) + self.driver.wait_catalog(page, "1.0.0", False) + offsets = self.snapshot() + self.driver.set_plugin_enabled(page, True) + self.driver.wait_catalog(page, "1.0.0", True) + self.select_existing(page) + gated = self.driver.wait_until( + lambda: next((event for event in events_after(self.state_root, offsets) + if event.get("event") == "initialize-gated" and event.get("token") == gate.token + and event.get("fixtureVersion") == marker), None), + 10, "the fixture to reach its controlled initialize boundary") + # No initialize response is possible until this exact token is + # released. Close/reopen while the old attach is still waiting. + self.close_reopen(page) + gate.release() + event = self.require_did_open(offsets, marker, "initialize-close-reopen") + self.driver.write_json(self.evidence / "initialize-order.json", {"gated": gated, "didOpen": event}) + + +def record_unreached_cases(reporter): + completed = {test["name"] for test in reporter.tests} + for name in CASES: + if name not in completed: + reporter.document_pending(name, "An earlier native acceptance step failed; this lifecycle case was not executed.", + "plugin-host-acceptance / native-lsp-lifecycle") diff --git a/scripts/plugin-host-acceptance/lsp_lifecycle_acceptance_test.py b/scripts/plugin-host-acceptance/lsp_lifecycle_acceptance_test.py new file mode 100644 index 0000000000..00c086ca9c --- /dev/null +++ b/scripts/plugin-host-acceptance/lsp_lifecycle_acceptance_test.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Bounded unit and real fixture-protocol checks, never native Lithe evidence.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +import acceptance_driver as driver +from lsp_lifecycle_acceptance import ( + InitializeGate, canonical_file_uri, event_offsets, events_after, matching_did_open, +) + + +SERVER = Path(__file__).with_name("fake-lsp-server.mjs").resolve() + + +def reject(action, expected=ValueError): + try: + action() + except expected: + return + raise AssertionError(f"expected {expected.__name__}") + + +def append(path, event): + with path.open("ab") as stream: + stream.write((json.dumps(event, ensure_ascii=False) + "\n").encode("utf-8")) + + +def did_open(uri, **extra): + return {"event": "message", "method": "textDocument/didOpen", "pid": 10, + "fixtureVersion": "v1", "uri": uri, "documentVersion": 1, **extra} + + +def offsets_and_partial_records(): + with tempfile.TemporaryDirectory(prefix="lithe-lsp-events-") as temporary: + root = Path(temporary) + workspace = root / "workspace" + workspace.mkdir() + events = workspace / "events.jsonl" + uri = (root / "中文 file.litheaccept").as_uri() + append(events, did_open(uri)) + offsets = event_offsets(root) + assert events_after(root, offsets) == [], "an old didOpen satisfied a new action" + fresh = did_open(uri, documentVersion=2) + with events.open("ab") as stream: + stream.write(json.dumps(fresh).encode("utf-8")) + assert events_after(root, offsets) == [], "a partial record counted as complete" + with events.open("ab") as stream: + stream.write(b"\n") + rows = events_after(root, offsets) + assert len(rows) == 1 and rows[0]["documentVersion"] == 2 + assert rows[0]["stateDirectory"] == str(workspace.resolve()) + assert matching_did_open(rows, "v1", uri, {10}, lambda pid: pid == 10) == rows[0] + events.write_bytes(b"") + reject(lambda: events_after(root, offsets)) + + +def wrong_events_cannot_satisfy_attachment(): + uri = (Path(tempfile.gettempdir()) / "lithe fixture.litheaccept").resolve().as_uri() + valid = did_open(uri) + for change in ({"method": "textDocument/didClose"}, {"event": "server-started"}, + {"fixtureVersion": "old"}, {"uri": uri + "-other"}, + {"uri": "https://example.invalid/file"}, {"documentVersion": -1}, + {"documentVersion": None}): + assert matching_did_open([{**valid, **change}], "v1", uri, {10}, lambda _: True) is None, change + assert matching_did_open([valid], "v1", uri, set(), lambda _: True) is None + assert matching_did_open([valid], "v1", uri, {10}, lambda _: False) is None + assert canonical_file_uri("file://remote.invalid/share/file") is None + assert canonical_file_uri(uri + "?query=unrelated") is None + + +def new_state_logs_are_scoped(): + with tempfile.TemporaryDirectory(prefix="lithe-lsp-scope-") as temporary: + parent = Path(temporary) + root, other = parent / "plugin-state", parent / "other-plugin" + root.mkdir() + other.mkdir() + offsets = event_offsets(root) + workspace = root / "new-workspace" + workspace.mkdir() + uri = (parent / "one.litheaccept").as_uri() + append(other / "events.jsonl", did_open(uri, pid=999)) + append(workspace / "events.jsonl", did_open(uri, pid=10)) + rows = events_after(root, offsets) + assert len(rows) == 1 and rows[0]["pid"] == 10 + # A directory junction must not smuggle another plugin's evidence into + # this root. Creating the junction is privilege-free on Windows. + link = root / "outside" + result = driver.run_bounded_process(["cmd", "/c", "mklink", "/J", str(link), str(other)], 5) + assert result.returncode == 0, result.stderr + try: + reject(lambda: events_after(root, offsets)) + finally: + link.rmdir() + + +def gate_failure_releases_only_owned_state(): + with tempfile.TemporaryDirectory(prefix="lithe-lsp-gate-") as temporary: + parent = Path(temporary) + root, workspace = parent / "state", parent / "state" / "workspace" + workspace.mkdir(parents=True) + unrelated = workspace / "other-state.txt" + unrelated.write_text("preserve", encoding="utf-8") + gate = InitializeGate(workspace, root) + try: + with gate: + assert json.loads(gate.path.read_text(encoding="utf-8"))["token"] == gate.token + raise RuntimeError("controlled failure") + except RuntimeError: + pass + assert not gate.path.exists() and gate.release_path.exists() + assert unrelated.read_text(encoding="utf-8") == "preserve" + reject(lambda: InitializeGate(parent, root)) + reject(lambda: InitializeGate(root, root)) + + +def frame(message): + body = json.dumps(message).encode("utf-8") + return f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + body + + +def response(path): + body = path.read_bytes() + if b"\r\n\r\n" not in body: + return None + header, payload = body.split(b"\r\n\r\n", 1) + size = int(header.split(b":", 1)[1]) + return json.loads(payload[:size]) if len(payload) >= size else None + + +def fixture_protocol(interrupt=False): + with tempfile.TemporaryDirectory(prefix="lithe-lsp-protocol-") as temporary: + root = Path(temporary) + workspace = root / "workspace" + workspace.mkdir() + stdout, stderr = root / "stdout.bin", root / "stderr.log" + handles = [] + process = None + try: + with InitializeGate(workspace, root) as gate, stdout.open("wb") as output, stderr.open("wb") as errors: + process = subprocess.Popen( + [shutil.which("node"), str(SERVER), "--state-dir", str(workspace), "--fixture-version", "gate-fixture"], + stdin=subprocess.PIPE, stdout=output, stderr=errors, creationflags=driver.CREATE_NO_WINDOW) + owner = driver.remember_owned_process(process.pid) + assert owner is not None + handles.append(owner) + driver.wait_until(lambda: list(workspace.glob("child-active-*.json")), 5, "the real fixture child") + handles.extend(driver.capture_owned_tree(owner)) + assert len(handles) >= 2 + process.stdin.write(frame({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})) + process.stdin.flush() + offsets = {} + gated = driver.wait_until(lambda: next((event for event in events_after(root, offsets) + if event.get("event") == "initialize-gated"), None), + 5, "the protocol initialize gate") + assert gated["token"] == gate.token and response(stdout) is None + if interrupt: + process.stdin.close() + process.wait(timeout=3) + assert process.returncode == 0 + stopped = events_after(root, offsets)[-1] + assert stopped["event"] == "server-stopped" and stopped["reason"] == "stdin-end" + else: + gate.release() + initialized = driver.wait_until(lambda: response(stdout), 5, "the gated initialize response") + assert initialized["id"] == 1 + uri = (root / "中文 document.litheaccept").as_uri() + process.stdin.write(frame({"jsonrpc": "2.0", "method": "textDocument/didOpen", "params": { + "textDocument": {"uri": uri, "version": 7, "text": "fixture"}}})) + process.stdin.flush() + event = driver.wait_until(lambda: matching_did_open( + events_after(root, offsets), "gate-fixture", uri, {owner.pid}, lambda _: owner.running()), + 5, "the URI and version in the real fixture event") + assert event["documentVersion"] == 7 + process.stdin.write(frame({"jsonrpc": "2.0", "id": 2, "method": "shutdown"}) + + frame({"jsonrpc": "2.0", "method": "exit"})) + process.stdin.flush() + process.wait(timeout=3) + assert process.returncode == 0 + finally: + if process is not None: + try: + driver.terminate_process_tree(process.pid, timeout_seconds=3) + process.wait(timeout=3) + finally: + if process.stdin is not None and not process.stdin.closed: + process.stdin.close() + for handle in set(handles): + assert not handle.running(), f"fixture process {handle.pid} survived cleanup" + handle.close() + if driver.owned_processes.get(handle.pid) is handle: + del driver.owned_processes[handle.pid] + + +CASES = { + "event offsets reject old and partial records": offsets_and_partial_records, + "didOpen requires the exact marker URI version and current owned PID": wrong_events_cannot_satisfy_attachment, + "new logs remain inside the exact plugin state scope": new_state_logs_are_scoped, + "failed gate actions release their owned state without deleting unrelated files": gate_failure_releases_only_owned_state, + "real fixture initialize waits for release then records didOpen URI and version": fixture_protocol, + "interrupted initialize closes its gate and reclaims the real fixture tree": lambda: fixture_protocol(True), +} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--report", type=Path) + parser.add_argument("--case", choices=list(CASES)) + args = parser.parse_args() + if args.case: + CASES[args.case]() + return 0 + if not args.report: + parser.error("--report is required") + tests = [] + for name in CASES: + started = time.monotonic() + status, details = "passed", "" + try: + result = driver.run_bounded_process([sys.executable, "-B", str(Path(__file__).resolve()), "--case", name], 15) + if result.returncode: + status, details = "failed", result.stderr or result.stdout + except Exception as error: + status, details = "failed", f"{type(error).__name__}: {error}" + tests.append({"name": name, "suite": "lsp-lifecycle-acceptance / fixture-unit", "status": status, + "durationMs": round((time.monotonic() - started) * 1000, 3), "maxMs": 15000, "details": details}) + driver.write_json(args.report, {"runner": "acceptance", "warnMs": 2000, "maxMs": 15000, "tests": tests}) + print(f"{status.upper()} {name}", flush=True) + root = Path(__file__).resolve().parents[2] + generator = root / ".agents/skills/write-stable-tests/scripts/generate-test-report.mjs" + code = "import {writeTestReportArtifacts} from '" + generator.as_uri() + "';writeTestReportArtifacts(process.argv[1]);" + artifacts = driver.run_bounded_process(["node", "--input-type=module", "-e", code, str(args.report.resolve())], 15) + assert artifacts.returncode == 0, artifacts.stderr + return 1 if any(test["status"] != "passed" for test in tests) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/marketplace_acceptance.py b/scripts/plugin-host-acceptance/marketplace_acceptance.py new file mode 100644 index 0000000000..c7ef66d516 --- /dev/null +++ b/scripts/plugin-host-acceptance/marketplace_acceptance.py @@ -0,0 +1,548 @@ +"""Optional real-marketplace lane for acceptance_driver; no mocked native answers. + +Public-source browsing is usable without a controlled publisher. The transaction +lane additionally needs the generated fixture and a certificate already trusted +for its reachable HTTPS hostname. Host IPC checks are named separately from UI +actions; lightweight HTML locator checks are never reported as native execution. +""" + +from __future__ import annotations + +import json +import re +import uuid +from pathlib import Path + +from playwright.sync_api import Page, expect + +from marketplace_fixture import FixtureCatalog, FixtureServer, PUBLIC_SOURCES + + +PUBLIC_CASES = ( + "marketplace public HTTPS refresh and search", + "marketplace public source add, disable, remove, and official protection", +) +LATE_UI_CASE = "marketplace native UI cancels late prepare on cancel, scope switch, and page close" +CONTROLLED_CASES = ( + "marketplace controlled source management and same-ID offers", + "marketplace host rejects missing approval and a wrong review digest", + "marketplace UI cancel and source switch discard prepared reviews", + "marketplace late prepare is cancelled by source disable and removal", + "marketplace UI shows complete permissions and programs before install", + "marketplace installation and source provenance survive restart", + "marketplace competing source cannot take over an installed ID", + "marketplace rejects archive digest and untrusted publisher signatures", + "marketplace source refresh invalidates an earlier host review", + "marketplace disabled installation updates only from its original source", + "marketplace disable and uninstall revoke processes and stale reviews", + LATE_UI_CASE, +) +SIGNATURE_GAP = "marketplace official publisher-signed Windows package" + + +def add_arguments(parser) -> None: + parser.add_argument("--marketplace", action="store_true", + help="Also exercise real HTTPS marketplace UI/IPC after the disk/LSP lane.") + parser.add_argument("--marketplace-fixture-root", type=Path, + help="Generated marketplace_fixture.py directory; never an installed app directory.") + parser.add_argument("--marketplace-tls-cert", type=Path, + help="Existing certificate chain valid and already trusted for the plan's HTTPS hostname.") + parser.add_argument("--marketplace-tls-key", type=Path, + help="Existing TLS key file; its contents are never copied into artifacts.") + parser.add_argument("--marketplace-listen-host", default="127.0.0.1") + + +def validate_arguments(parser, args) -> None: + supplied = (args.marketplace_fixture_root, args.marketplace_tls_cert, args.marketplace_tls_key) + if any(supplied) and (not args.marketplace or not all(supplied)): + parser.error("controlled marketplace execution requires --marketplace, --marketplace-fixture-root, --marketplace-tls-cert and --marketplace-tls-key together") + + +def settings(page: Page): + return page.locator("[data-settings-content='declarative-plugins']") + + +def marketplace(page: Page): + return settings(page).get_by_test_id("marketplace-page") + + +def source_row(page: Page, source_id: str): + return settings(page).get_by_test_id(f"declarative-marketplace-source-{source_id}").locator("xpath=ancestor::li[1]") + + +def offer_row(page: Page, plugin_id: str): + return marketplace(page).get_by_test_id(f"marketplace-plugin-{plugin_id}") + + +def choose_source(page: Page, source_id: str) -> None: + select = marketplace(page).get_by_test_id("marketplace-source-select") + expect(select).to_be_enabled(timeout=15_000) + select.select_option(source_id, timeout=15_000) + expect(select).to_have_value(source_id, timeout=15_000) + + +def native_call(page: Page, command: str, arguments: dict | None = None): + # Fetches have the production host's 60s deadline. The observer allows + # another 10s for cancellation/publication without weakening that deadline. + timeout_ms = 70_000 if command.endswith(("_refresh", "_prepare")) else 10_000 + result = page.evaluate("""async ([command, args, timeout]) => { + let timer; + try { + const value = await Promise.race([ + window.__TAURI_INTERNALS__.invoke(command, args), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`acceptance IPC deadline: ${command}`)), timeout); + }), + ]); + return {ok: true, value}; + } catch (error) { return {ok: false, error: String(error)}; } + finally { clearTimeout(timer); } + }""", [command, arguments or {}, timeout_ms]) + if not result["ok"]: + raise RuntimeError(result["error"]) + return result["value"] + + +def expect_rejected(page: Page, command: str, arguments: dict, pattern: str) -> str: + try: + native_call(page, command, arguments) + except RuntimeError as error: + assert re.search(pattern, str(error), re.IGNORECASE), str(error) + return str(error) + raise AssertionError(f"the real host unexpectedly accepted {command}") + + +def read_review_programs(page: Page, *, marker: str | None = None) -> list[dict]: + card = marketplace(page).get_by_test_id("marketplace-review-card") + expect(card).to_be_visible(timeout=75_000) + expect(card.get_by_test_id("marketplace-unverified-notice")).to_be_visible() + expect(card.get_by_test_id("marketplace-official-badge")).to_have_count(0) + assert card.locator("ul li").all_text_contents() == ["languageServer.launch"] + declaration = card.locator("pre") + if not declaration.is_visible(): + card.get_by_test_id("marketplace-review-programs-toggle").click() + expect(declaration).to_be_visible(timeout=5_000) + programs = json.loads(declaration.inner_text(timeout=5_000)) + if marker is not None: + # This is the fixture's expected public review contract, including the + # discovered runtime, every argument and the empty validation probe. + expected = [ + {"kind": "runtime", "tool": "node"}, + {"kind": "runtimeLanguageServer", "tool": "node", "packagedRuntime": None, + "entry": "server/fake-lsp-server.mjs", "runtimeArguments": [], + "arguments": ["--stdio", "--state-dir", "${workspaceStateDir}", + "--fixture-version", marker], "validationArguments": [], + "languageID": "litheaccept"}, + ] + canonical = lambda entries: sorted(json.dumps(item, sort_keys=True) for item in entries) + assert canonical(programs) == canonical(expected), programs + return programs + + +class MarketplaceAcceptance: + def __init__(self, driver, args, reporter, page: Page, restart, owned_fixture_pids: set[int]): + self.driver = driver + self.args = args + self.reporter = reporter + self.page = page + self.restart = restart + self.pids = owned_fixture_pids + self.evidence = args.artifact_root.resolve() / "marketplace" + self.evidence.mkdir(parents=True, exist_ok=True) + self.operations: set[str] = set() + + def sources(self): + return native_call(self.page, "declarative_marketplace_sources_state")["sources"] + + def indexes(self): + return native_call(self.page, "declarative_marketplace_list") + + def source(self, url: str): + return next((item for item in self.sources() if item["indexURL"].lower() == url.lower()), None) + + def add_source(self, url: str): + form = settings(self.page).get_by_test_id("declarative-marketplace-sources") + form.locator("input[type='text']").fill(url) + form.get_by_role("button", name="Add source", exact=True).click() + source = self.driver.wait_until(lambda: self.source(url), 15, "the UI-added native source") + expect(source_row(self.page, source["id"])).to_be_visible(timeout=15_000) + return source + + def enable_source(self, source_id: str, enabled: bool): + toggle = source_row(self.page, source_id).get_by_role("switch") + expect(toggle).to_be_enabled(timeout=15_000) + if toggle.get_attribute("aria-checked") != str(enabled).lower(): + toggle.click() + expect(toggle).to_have_attribute("aria-checked", str(enabled).lower(), timeout=15_000) + self.driver.wait_until(lambda: any(item["id"] == source_id and item["isEnabled"] is enabled + for item in self.sources()), 15, "native source enabled state") + + def remove_source(self, source_id: str): + row = source_row(self.page, source_id) + row.get_by_role("button", name="Remove", exact=True).click(timeout=15_000) + expect(row).to_have_count(0, timeout=15_000) + assert all(item["id"] != source_id for item in self.sources()) + + def refresh(self, source_id: str, *, digest: str | None = None): + choose_source(self.page, source_id) + button = marketplace(self.page).get_by_test_id("marketplace-refresh-button") + expect(button).to_be_enabled(timeout=15_000) + button.click() + def ready(): + item = next((item for item in self.indexes() if item["sourceId"] == source_id), None) + if not item or item["index"] is None: + return None + if digest is not None and not any(plugin.get("availability", {}).get("sha256") == digest + for plugin in item["index"]["plugins"]): + return None + return item["index"] + index = self.driver.wait_until(ready, 75, "Core-validated HTTPS source cache") + expect(button).to_have_text("Refresh", timeout=75_000) + expect(marketplace(self.page).get_by_test_id(f"marketplace-source-status-{source_id}") + .get_by_role("alert")).to_have_count(0) + self.driver.write_json(self.evidence / f"index-{source_id}.json", index) + return index + + def search(self, index: dict): + field = marketplace(self.page).get_by_test_id("marketplace-search-input") + if index["plugins"]: + first = index["plugins"][0] + field.fill(first["name"]) + expect(offer_row(self.page, first["id"])).to_be_visible() + field.fill("lithe-acceptance-no-match-" + uuid.uuid4().hex) + expect(marketplace(self.page).get_by_test_id("marketplace-empty")).to_be_visible() + field.fill("") + + def operation(self) -> str: + value = "acceptance-" + uuid.uuid4().hex + self.operations.add(value) + return value + + def prepare(self, source_id: str): + operation = self.operation() + return native_call(self.page, "declarative_marketplace_prepare", { + "operationId": operation, "sourceId": source_id, "pluginId": self.driver.PLUGIN_ID, + }) + + def cancel(self, operation: str): + native_call(self.page, "declarative_marketplace_cancel", {"operationId": operation}) + self.operations.discard(operation) + + def no_snapshots(self): + root = self.args.app_local_data / "plugin-temp" + self.driver.wait_until(lambda: not root.exists() or not any(root.iterdir()), + 15, "cancelled marketplace downloads and snapshots") + + def no_install(self): + assert not (self.args.app_local_data / "plugins" / self.driver.PLUGIN_ID).exists() + self.no_snapshots() + + def ui_prepare(self, source_id: str): + choose_source(self.page, source_id) + row = offer_row(self.page, self.driver.PLUGIN_ID) + row.get_by_test_id(f"marketplace-install-{self.driver.PLUGIN_ID}").click(timeout=15_000) + card = marketplace(self.page).get_by_test_id("marketplace-review-card") + result = marketplace(self.page).locator( + "[data-testid='marketplace-review-card'], " + f"[data-testid='marketplace-plugin-{self.driver.PLUGIN_ID}'] [role='alert']" + ) + result.first.wait_for(state="visible", timeout=75_000) + assert card.is_visible(), row.inner_text(timeout=5_000) + return card + + def assert_record(self, source: dict, asset: dict, programs: list[dict] | None = None): + record = self.driver.installation_record(self.args.app_local_data) + market = record["marketplace"] + assert record["schemaVersion"] == 2 and record["origin"] == "community" + assert record.get("disk") is None + assert record["version"] == asset["version"] + assert market["sourceId"] == source["id"] and market["indexUrl"] == source["indexURL"] + assert market["archiveSha256"] == asset["sha256"] + assert market["archiveUrl"].endswith("/" + asset["file"]) + assert re.fullmatch(r"[0-9a-f]{64}", market["contentDigest"]) + if programs is not None: + approved = [json.loads(program) for program in market["approvedPrograms"]] + assert sorted(json.dumps(p, sort_keys=True) for p in approved) == sorted( + json.dumps(p, sort_keys=True) for p in programs) + assert self.driver.active_fixture_marker(self.args.app_local_data, asset["version"]) == asset["marker"] + self.driver.write_json(self.evidence / f"installation-{asset['version']}.json", record) + return record + + def confirm_rejected(self, review: dict, pattern: str, *, approved=True, digest=None): + try: + return expect_rejected(self.page, "declarative_marketplace_confirm", { + "operationId": review["operationId"], "approved": approved, + "manifestDigest": review["prepared"]["manifestDigest"] if digest is None else digest, + }, pattern) + finally: + self.cancel(review["operationId"]) + + def run_public(self): + self.driver.open_plugin_settings(self.page) + with self.reporter.step(PUBLIC_CASES[0]): + current = self.sources() + public_indexes = [] + for url in PUBLIC_SOURCES: + source = next((item for item in current if item["indexURL"] == url), None) + assert source is not None, f"the public preset is missing: {url}" + self.enable_source(source["id"], True) + index = self.refresh(source["id"]) + self.search(index) + public_indexes.append({"source": source, "index": index}) + for plugin in index["plugins"]: + row = offer_row(self.page, plugin["id"]) + expect(row).to_be_visible() + if plugin["availability"]["status"] != "available": + expect(row.get_by_test_id(f"marketplace-install-{plugin['id']}")).to_have_count(0) + self.driver.write_json(self.evidence / "public-catalogs.json", public_indexes) + with self.reporter.step(PUBLIC_CASES[1]): + official = next(item for item in self.sources() if item["isOfficial"]) + expect(source_row(self.page, official["id"]).get_by_role("button", name="Remove", exact=True)).to_have_count(0) + expect_rejected(self.page, "declarative_marketplace_sources_remove", { + "sourceId": official["id"], + }, "officialSourceCannotBeRemoved") + community = self.source(PUBLIC_SOURCES[1]) + self.enable_source(community["id"], False) + self.driver.wait_until(lambda: all(item["sourceId"] != community["id"] for item in self.indexes()), + 15, "disabled public source to leave the catalog") + self.enable_source(community["id"], True) + self.remove_source(community["id"]) + added = self.add_source(PUBLIC_SOURCES[1]) + assert added["id"] == community["id"] + + def late_prepare(self, fixture: FixtureCatalog, source: dict, *, remove: bool): + gate = fixture.gate_next_archive() + operation = self.operation() + # Start one genuine host request without awaiting it. Observation lives + # in a separate JS slot; invoke and its result are never replaced. + self.page.evaluate("""([operationId, sourceId, pluginId]) => { + window.__marketplaceAcceptanceResult = {done: false}; + window.__TAURI_INTERNALS__.invoke('declarative_marketplace_prepare', + {operationId, sourceId, pluginId}).then( + value => { window.__marketplaceAcceptanceResult = {done: true, ok: true, value}; }, + error => { window.__marketplaceAcceptanceResult = {done: true, ok: false, error: String(error)}; }, + ); + }""", [operation, source["id"], self.driver.PLUGIN_ID]) + try: + assert gate.arrived.wait(timeout=10), "the real HTTPS archive request did not reach the fixture" + if remove: + self.remove_source(source["id"]) + else: + self.enable_source(source["id"], False) + self.page.wait_for_function("() => window.__marketplaceAcceptanceResult?.done", timeout=15_000) + result = self.page.evaluate("() => window.__marketplaceAcceptanceResult") + assert not result["ok"] and "cancel" in result["error"].lower(), result + self.driver.write_json(self.evidence / f"late-prepare-{'removed' if remove else 'disabled'}.json", result) + finally: + gate.release.set() + self.cancel(operation) + assert gate.finished.wait(timeout=6), "the gated archive handler survived cancellation" + self.no_install() + + def late_ui_prepare(self, fixture: FixtureCatalog, source: dict, other: dict, mode: str): + """Hold real HTTPS bytes until the UI has released its owning context.""" + if mode not in ("cancel", "scope", "dispose"): + raise ValueError(f"unsupported late UI action: {mode}") + gate = fixture.gate_next_archive() + try: + choose_source(self.page, source["id"]) + row = offer_row(self.page, self.driver.PLUGIN_ID) + row.get_by_test_id(f"marketplace-install-{self.driver.PLUGIN_ID}").click(timeout=15_000) + assert gate.arrived.wait(timeout=10), "the UI's real archive request did not reach the fixture" + if mode == "cancel": + row.get_by_role("button", name="Cancel", exact=True).click(timeout=15_000) + elif mode == "scope": + choose_source(self.page, other["id"]) + elif mode == "dispose": + self.driver.close_settings(self.page) + except Exception: + # Unmounting the page releases its own native operation even if + # the assertion failed before we could click the expected action. + self.driver.close_settings(self.page) + raise + finally: + gate.release.set() + # If starting the request itself failed, teardown owns the unused + # gate. A request that arrived must release its active handler here. + if gate.arrived.is_set(): + assert gate.finished.wait(timeout=6), "the UI's gated archive handler survived cancellation" + self.no_install() + self.driver.open_plugin_settings(self.page) + choose_source(self.page, source["id"]) + expect(marketplace(self.page).get_by_test_id("marketplace-review-card")).to_have_count(0) + expect(offer_row(self.page, self.driver.PLUGIN_ID) + .get_by_test_id(f"marketplace-install-{self.driver.PLUGIN_ID}")).to_be_enabled(timeout=15_000) + self.driver.write_json(self.evidence / f"late-ui-{mode}.json", { + "action": mode, "archiveRequestReachedFixture": True, + "reviewAfterRelease": False, "installedPackageAfterRelease": False, + }) + + def run_controlled(self, fixture: FixtureCatalog): + assets = fixture.plan["assets"] + urls = [f"{fixture.base}/source-{name}/index.json" for name in ("a", "b")] + with self.reporter.step(CONTROLLED_CASES[0]): + for source in self.sources(): + if source["isEnabled"]: + self.enable_source(source["id"], False) + a, b = (self.add_source(url) for url in urls) + self.enable_source(a["id"], False) + self.enable_source(a["id"], True) + self.remove_source(b["id"]) + b = self.add_source(urls[1]) + index = self.refresh(a["id"], digest=assets["a1"]["sha256"]) + self.refresh(b["id"], digest=assets["b9"]["sha256"]) + choose_source(self.page, "all") + expect(offer_row(self.page, self.driver.PLUGIN_ID)).to_have_count(1) + expect(offer_row(self.page, self.driver.PLUGIN_ID)).to_contain_text("1.0.0") + choose_source(self.page, b["id"]) + expect(offer_row(self.page, self.driver.PLUGIN_ID)).to_contain_text("9.0.0") + choose_source(self.page, a["id"]) + self.search(index) + with self.reporter.step(CONTROLLED_CASES[1]): + review = self.prepare(a["id"]) + assert review["requiresConfirmation"] is True + assert review["officialPublisherVerified"] is False + self.confirm_rejected(review, "explicit approval is required", approved=False) + self.no_install() + self.confirm_rejected(self.prepare(a["id"]), "review digest does not match", digest="0" * 64) + self.no_install() + with self.reporter.step(CONTROLLED_CASES[2]): + card = self.ui_prepare(a["id"]) + read_review_programs(self.page, marker=assets["a1"]["marker"]) + card.get_by_test_id("marketplace-review-cancel").click() + expect(card).to_have_count(0) + self.no_install() + card = self.ui_prepare(a["id"]) + choose_source(self.page, b["id"]) + expect(card).to_have_count(0) + self.no_install() + with self.reporter.step(LATE_UI_CASE): + for mode in ("cancel", "scope", "dispose"): + self.late_ui_prepare(fixture, a, b, mode) + with self.reporter.step(CONTROLLED_CASES[3]): + self.late_prepare(fixture, a, remove=False) + self.enable_source(a["id"], True) + self.late_prepare(fixture, a, remove=True) + a = self.add_source(urls[0]) + self.refresh(a["id"], digest=assets["a1"]["sha256"]) + # Cancellation may arrive before registration too. + operation = self.operation() + self.cancel(operation) + expect_rejected(self.page, "declarative_marketplace_prepare", { + "operationId": operation, "sourceId": a["id"], "pluginId": self.driver.PLUGIN_ID, + }, "cancel") + self.no_install() + with self.reporter.step(CONTROLLED_CASES[4]): + card = self.ui_prepare(a["id"]) + assert a["indexURL"] in card.inner_text() + programs = read_review_programs(self.page, marker=assets["a1"]["marker"]) + card.get_by_test_id("marketplace-review-confirm").click() + expect(card).to_have_count(0, timeout=45_000) + self.driver.wait_catalog(self.page, "1.0.0", True) + self.assert_record(a, assets["a1"], programs) + with self.reporter.step(CONTROLLED_CASES[5]): + self.page = self.restart() + self.driver.open_plugin_settings(self.page) + self.driver.wait_catalog(self.page, "1.0.0", True) + self.assert_record(a, assets["a1"], programs) + assert self.source(urls[0])["id"] == a["id"] + with self.reporter.step(CONTROLLED_CASES[6]): + choose_source(self.page, b["id"]) + expect(offer_row(self.page, self.driver.PLUGIN_ID) + .get_by_test_id(f"marketplace-install-{self.driver.PLUGIN_ID}")).to_have_count(0) + operation = self.operation() + expect_rejected(self.page, "declarative_marketplace_prepare", { + "operationId": operation, "sourceId": b["id"], "pluginId": self.driver.PLUGIN_ID, + }, "source|marketplace") + self.cancel(operation) + self.assert_record(a, assets["a1"]) + with self.reporter.step(CONTROLLED_CASES[7]): + for phase, digest, pattern in ( + ("bad-digest", "0" * 64, "SHA-256|sha256|digest|hash"), + ("bad-signature", assets["untrusted"]["sha256"], "signature|signing|key|publisher"), + ): + fixture.stage(phase) + self.refresh(a["id"], digest=digest) + operation = self.operation() + error = expect_rejected(self.page, "declarative_marketplace_prepare", { + "operationId": operation, "sourceId": a["id"], "pluginId": self.driver.PLUGIN_ID, + }, pattern) + self.driver.write_json(self.evidence / f"rejected-{phase}.json", {"error": error}) + self.cancel(operation) + self.no_snapshots() + self.assert_record(a, assets["a1"]) + fixture.stage("update") + self.refresh(a["id"], digest=assets["a2"]["sha256"]) + with self.reporter.step(CONTROLLED_CASES[8]): + review = self.prepare(a["id"]) + self.refresh(a["id"], digest=assets["a2"]["sha256"]) + self.confirm_rejected(review, "changed|cancel|no longer|pending|unknown") + self.assert_record(a, assets["a1"]) + with self.reporter.step(CONTROLLED_CASES[9]): + self.driver.close_settings(self.page) + self.driver.open_fixture_file(self.page) + state = self.args.app_local_data / "plugin-state" / self.driver.PLUGIN_ID + live = self.driver.wait_for_server_count(state, 2) + self.pids.update(live) + self.driver.open_plugin_settings(self.page) + self.driver.set_plugin_enabled(self.page, False) + self.driver.wait_pids_dead(live, "marketplace plugin disable") + choose_source(self.page, "all") + # A newer 9.0.0 offer cannot displace the installed source's 1.1.0. + row = offer_row(self.page, self.driver.PLUGIN_ID) + expect(row).to_contain_text("1.1.0") + expect(row).not_to_contain_text("9.0.0") + card = self.ui_prepare(a["id"]) + programs = read_review_programs(self.page, marker=assets["a2"]["marker"]) + card.get_by_test_id("marketplace-review-confirm").click() + expect(card).to_have_count(0, timeout=45_000) + self.driver.wait_catalog(self.page, "1.1.0", False) + assert self.assert_record(a, assets["a2"], programs)["enabled"] is False + with self.reporter.step(CONTROLLED_CASES[10]): + self.driver.set_plugin_enabled(self.page, True) + self.driver.open_fixture_file(self.page) + live = self.driver.wait_for_server_count(state, 2) + self.pids.update(live) + self.driver.open_plugin_settings(self.page) + stale = self.prepare(a["id"]) + details = self.driver.select_plugin_details(self.page) + details.get_by_role("button", name="Uninstall", exact=True).click() + expect(self.driver.plugin_row(self.page)).to_have_count(0, timeout=45_000) + self.driver.wait_pids_dead(live, "marketplace plugin uninstall") + self.confirm_rejected(stale, "changed|cancel|no longer|pending|unknown") + assert not state.exists() + self.no_install() + + def run(self): + self.reporter.document_pending(SIGNATURE_GAP, + "Requires a real Windows artifact signed by the pinned official publisher. Fixture packages are unsigned third-party packages and never establish official-publisher acceptance.") + try: + self.run_public() + if self.args.marketplace_fixture_root is None: + for name in CONTROLLED_CASES: + self.reporter.document_pending(name, + "Needs marketplace_fixture.py generate output plus a reachable HTTPS hostname and an already-trusted certificate/key. No trust-store changes or TLS bypass are permitted.") + else: + fixture = FixtureCatalog(self.args.marketplace_fixture_root.resolve()) + with FixtureServer.native(fixture, self.args.marketplace_tls_cert, + self.args.marketplace_tls_key, self.args.marketplace_listen_host): + try: + self.run_controlled(fixture) + finally: + self.driver.write_json(self.evidence / "fixture-requests.json", fixture.requests) + finally: + cleanup_errors = [] + for operation in list(self.operations): + try: + self.cancel(operation) + except Exception as error: + cleanup_errors.append(str(error)) + if cleanup_errors: + raise RuntimeError("marketplace operation cleanup failed: " + "; ".join(cleanup_errors)) + + +def record_unreached_cases(reporter) -> None: + completed = {test["name"] for test in reporter.tests} + for name in PUBLIC_CASES + CONTROLLED_CASES + (SIGNATURE_GAP,): + if name not in completed: + reporter.document_pending(name, "An earlier acceptance case failed; this native marketplace case was not executed.") diff --git a/scripts/plugin-host-acceptance/marketplace_acceptance_test.py b/scripts/plugin-host-acceptance/marketplace_acceptance_test.py new file mode 100644 index 0000000000..cadadc68e2 --- /dev/null +++ b/scripts/plugin-host-acceptance/marketplace_acceptance_test.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +"""Bounded fixture/locator regressions. These are never native product acceptance.""" + +from __future__ import annotations + +import argparse +import contextlib +import hashlib +import io +import json +import sys +import tempfile +import threading +import time +import urllib.error +import urllib.request +import zipfile +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import acceptance_driver as driver +from marketplace_acceptance import ( + CONTROLLED_CASES, PUBLIC_CASES, MarketplaceAcceptance, add_arguments, + choose_source, expect_rejected, marketplace, native_call, offer_row, + read_review_programs, record_unreached_cases, source_row, validate_arguments, +) +from marketplace_fixture import ( + FixtureCatalog, FixtureServer, HttpsRedirects, PHASES, + generate_fixture, https_base, +) +from playwright.sync_api import expect, sync_playwright + + +BASE = "https://marketplace.acceptance.invalid:8443/run" +SERVER = Path(__file__).with_name("fake-lsp-server.mjs") + + +def rejects(action, error_type=ValueError): + try: + action() + except error_type: + return + raise AssertionError(f"expected {error_type.__name__}") + + +def fixture_case(action): + with tempfile.TemporaryDirectory(prefix="lithe-market-fixture-") as directory: + root = Path(directory) / "fixture" + generate_fixture(root, BASE, SERVER) + action(root) + + +def deterministic_archives(): + with tempfile.TemporaryDirectory(prefix="lithe-market-determinism-") as directory: + roots = [Path(directory) / name for name in ("one", "two")] + plans = [generate_fixture(root, BASE, SERVER) for root in roots] + assert plans[0] == plans[1] + for name, asset in plans[0]["assets"].items(): + first, second = (root / asset["file"] for root in roots) + assert first.read_bytes() == second.read_bytes(), name + assert hashlib.sha256(first.read_bytes()).hexdigest() == asset["sha256"] + with zipfile.ZipFile(first) as archive: + manifest = json.loads(archive.read("plugin.json")) + assert manifest["id"] == driver.PLUGIN_ID + assert manifest["version"] == asset["version"] + assert manifest["vendor"]["signatureRequirement"] == "none" + assert manifest["permissions"] == ["languageServer.launch"] + assert manifest["languageServers"][0]["arguments"][-1] == asset["marker"] + if name == "untrusted": + signature = json.loads(archive.read("lithe-plugin-signature.json")) + assert signature["keyId"] == "acceptance-untrusted" + else: + assert "lithe-plugin-signature.json" not in archive.namelist() + rejects(lambda: generate_fixture(roots[0], BASE, SERVER), FileExistsError) + + +def https_configuration_only(): + assert https_base(BASE + "/") == BASE + for invalid in ("http://localhost:8000", "file:///fixture", "https://user:secret@example.com", + "https://example.com/a?token=secret", "https://example.com/#section", + "https://example.com/../other", "https://example.com:0"): + rejects(lambda: https_base(invalid)) + redirect = HttpsRedirects() + rejects(lambda: redirect.redirect_request(None, None, 302, "", {}, "http://example.com/index.json")) + parser = argparse.ArgumentParser() + add_arguments(parser) + plain = parser.parse_args(["--marketplace"]) + validate_arguments(parser, plain) + with contextlib.redirect_stderr(io.StringIO()): + partial = parser.parse_args(["--marketplace", "--marketplace-fixture-root", "fixture"]) + rejects(lambda: validate_arguments(parser, partial), SystemExit) + + +def phases_and_corruption(root): + catalog = FixtureCatalog(root) + a = catalog.prefix + "/source-a/index.json" + b = catalog.prefix + "/source-b/index.json" + assert json.loads(catalog.response(b)[0])["plugins"][0]["versions"][0]["version"] == "9.0.0" + for phase in PHASES: + catalog.stage(phase) + document = json.loads(catalog.response(a)[0]) + version = document["plugins"][0]["versions"][0] + download = version["downloads"][0] + assert download["platforms"] == ["windows-x64"] + assert download["downloadURL"].startswith(BASE + "/packages/") + if phase == "bad-digest": + assert download["sha256"] == "0" * 64 + rejects(lambda: catalog.stage("outside-plan")) + assert catalog.response(catalog.prefix + "/../plan.json")[0] is None + (root / "packages/a1.zip").write_bytes(b"changed after generation") + rejects(lambda: FixtureCatalog(root)) + + +def gated_http_unit(root): + catalog = FixtureCatalog(root) + gate = catalog.gate_next_archive() + replies = [] + with FixtureServer(catalog, ("127.0.0.1", 0), None) as server: + url = f"http://127.0.0.1:{server.server.server_port}{catalog.prefix}/packages/a1.zip" + def request(): + try: + with urllib.request.urlopen(url, timeout=3) as response: + replies.append(response.read()) + except Exception as error: + replies.append(error) + client = threading.Thread(target=request, name="marketplace-unit-client") + client.start() + try: + assert gate.arrived.wait(timeout=2) + assert not gate.finished.is_set() + gate.release.set() + assert gate.finished.wait(timeout=2) + finally: + gate.release.set() + client.join(timeout=4) + assert not client.is_alive(), "the unit HTTP client survived its deadline" + assert len(replies) == 1 and isinstance(replies[0], bytes), replies + assert hashlib.sha256(replies[0]).hexdigest() == catalog.plan["assets"]["a1"]["sha256"] + with urllib.request.urlopen( + f"http://127.0.0.1:{server.server.server_port}{catalog.prefix}/source-a/index.json", timeout=2, + ) as response: + assert json.load(response) == catalog.plan["indexes"]["initial"] + try: + urllib.request.urlopen(f"http://127.0.0.1:{server.server.server_port}/plan.json", timeout=2) + except urllib.error.HTTPError as error: + assert error.code == 404 + else: + raise AssertionError("the fixture exposed its local plan or arbitrary files") + assert not server.thread.is_alive() + + +def shutdown_releases_gate(root): + catalog = FixtureCatalog(root) + gate = catalog.gate_next_archive() + server = FixtureServer(catalog, ("127.0.0.1", 0), None) + replies = [] + def request(): + try: + with urllib.request.urlopen( + f"http://127.0.0.1:{server.server.server_port}{catalog.prefix}/packages/a1.zip", timeout=3, + ) as response: + replies.append(response.read()) + except Exception as error: + replies.append(error) + client = threading.Thread(target=request, name="marketplace-cancelled-unit-client") + try: + with server: + client.start() + assert gate.arrived.wait(timeout=2) + # Assertion failures and cancellation must not leave a held request. + finally: + gate.release.set() + client.join(timeout=4) + assert not client.is_alive() and not server.thread.is_alive() + assert gate.finished.is_set() + assert len(replies) == 1 and isinstance(replies[0], Exception), replies + + +def missing_native_prerequisites(root): + reporter = driver.Reporter() + args = SimpleNamespace(artifact_root=root / "evidence", marketplace_fixture_root=None) + scenario = MarketplaceAcceptance(driver, args, reporter, None, None, set()) + with patch.object(scenario, "run_public"), patch.object(scenario, "run_controlled") as controlled: + scenario.run() + controlled.assert_not_called() + controlled_records = [test for test in reporter.tests if test["name"] in CONTROLLED_CASES] + assert len(controlled_records) == len(CONTROLLED_CASES) + assert all(test["status"] == "skipped" for test in controlled_records) + record_unreached_cases(reporter) + assert all(test["status"] == "skipped" for test in reporter.tests) + assert all(any(test["name"] == name for test in reporter.tests) for name in PUBLIC_CASES + CONTROLLED_CASES) + + +def marketplace_dom(browser_channel: str): + """Selectors and IPC argument regression against explicit HTML/native doubles.""" + with sync_playwright() as playwright: + browser = playwright.chromium.launch(channel=browser_channel, headless=True, timeout=10_000) + try: + page = browser.new_page() + page.set_default_timeout(2_000) + programs = [ + {"kind": "runtime", "tool": "node"}, + {"kind": "runtimeLanguageServer", "tool": "node", "packagedRuntime": None, + "entry": "server/fake-lsp-server.mjs", "runtimeArguments": [], + "arguments": ["--stdio", "--state-dir", "${workspaceStateDir}", + "--fixture-version", "market-a-1"], "validationArguments": [], + "languageID": "litheaccept"}, + ] + page.set_content(f""" +
+
  • Trap
  • +
    +
    +
    • Same name

      + +
    • +
    • Same name

      + +
    +
    +
    + +
    • + {driver.DISPLAY_NAME} · 1.0.0 +
    + +
    +
    + """, timeout=2_000) + page.locator("#programs").evaluate("(node, value) => node.textContent = JSON.stringify(value)", programs) + choose_source(page, "b") + expect(marketplace(page).get_by_test_id("marketplace-source-select")).to_have_value("b") + expect(offer_row(page, driver.PLUGIN_ID)).to_have_count(1) + source_row(page, "a").get_by_role("button", name="Remove", exact=True).click() + expect(page.locator("body")).to_have_attribute("data-removed", "a") + offer_row(page, driver.PLUGIN_ID).get_by_test_id(f"marketplace-install-{driver.PLUGIN_ID}").click() + assert read_review_programs(page, marker="market-a-1") == programs + # A truncated declaration must fail; the regression cannot merely + # check that 'node' appears somewhere in the approval card. + page.locator("#programs").evaluate("(node) => node.textContent = '[{\"tool\":\"node\"}]'") + rejects(lambda: read_review_programs(page, marker="market-a-1"), AssertionError) + marketplace(page).get_by_test_id("marketplace-review-cancel").click() + expect(marketplace(page).get_by_test_id("marketplace-review-card")).to_have_count(0) + page.evaluate("""() => { + window.__TAURI_INTERNALS__ = {invoke: async (command, args) => { + window.__observed = {command, args}; + if (args.approved === false) throw new Error('explicit approval is required'); + return {fixtureOnly: true}; + }}; + }""") + expect_rejected(page, "declarative_marketplace_confirm", { + "operationId": "unit-review", "manifestDigest": "a" * 64, "approved": False, + }, "approval is required") + observed = page.evaluate("() => window.__observed") + assert observed["args"]["approved"] is False + assert observed["command"] == "declarative_marketplace_confirm" + assert native_call(page, "unit-observer-only") == {"fixtureOnly": True} + assert page.locator("body").get_attribute("data-trap") is None + finally: + browser.close() + + +def run_tests(args) -> int: + tests = [] + def case(name, action, max_ms=10_000): + started = time.monotonic() + status, details = "passed", "" + try: + action() + except Exception as error: + status, details = "failed", f"{type(error).__name__}: {error}" + tests.append({"name": name, "suite": "plugin-marketplace-acceptance / fixture-and-locator-unit", + "status": status, "durationMs": round((time.monotonic() - started) * 1000, 3), + "maxMs": max_ms, "details": details}) + driver.write_json(args.report, {"runner": "acceptance", "warnMs": 2000, "maxMs": 10000, "tests": tests}) + if status != "passed": + raise AssertionError(details) + case("marketplace fixture archives and approval inputs are deterministic", deterministic_archives) + case("native fixture configuration rejects HTTP and partial TLS arguments", https_configuration_only) + case("fixture phases preserve selected bytes and reject modified archives", lambda: fixture_case(phases_and_corruption)) + case("unit HTTP gate releases the exact archive and denies arbitrary paths", lambda: fixture_case(gated_http_unit)) + case("fixture teardown releases a held request and listener", lambda: fixture_case(shutdown_releases_gate)) + case("missing native prerequisites remain explicitly skipped", lambda: fixture_case(missing_native_prerequisites)) + if args.dom_browser: + def dom_child(): + result = driver.run_bounded_process([ + sys.executable, "-B", str(Path(__file__).resolve()), + "--dom-browser", args.dom_browser, "--dom-fixture-child", + ], timeout_seconds=30) + assert result.returncode == 0, result.stderr or result.stdout + case("HTML marketplace selectors and approval IPC preserve scope and full declarations", dom_child, max_ms=35_000) + else: + tests.append({"name": "HTML marketplace locator regression", "suite": "plugin-marketplace-acceptance / fixture-and-locator-unit", + "status": "skipped", "durationMs": 0, "maxMs": 35_000, + "details": "Pass --dom-browser msedge; no Lithe product is launched by this unit fixture."}) + driver.write_json(args.report, {"runner": "acceptance", "warnMs": 2000, "maxMs": 10000, "tests": tests}) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--report", type=Path) + parser.add_argument("--dom-browser", choices=["msedge"]) + parser.add_argument("--test-child", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--dom-fixture-child", action="store_true", help=argparse.SUPPRESS) + args = parser.parse_args() + if args.dom_fixture_child: + if not args.dom_browser: + parser.error("--dom-fixture-child requires --dom-browser") + marketplace_dom(args.dom_browser) + return 0 + if not args.report: + parser.error("--report is required") + if args.test_child: + return run_tests(args) + command = [sys.executable, "-B", str(Path(__file__).resolve()), "--report", str(args.report.resolve()), "--test-child"] + if args.dom_browser: + command.extend(["--dom-browser", args.dom_browser]) + try: + result = driver.run_bounded_process(command, timeout_seconds=90) + print(result.stdout, end="") + print(result.stderr, end="", file=sys.stderr) + return result.returncode + except TimeoutError as error: + report = json.loads(args.report.read_text(encoding="utf-8")) if args.report.exists() else {"runner": "acceptance", "tests": []} + report["tests"].append({"name": "marketplace unit process watchdog", "suite": "plugin-marketplace-acceptance / fixture-and-locator-unit", + "status": "timeout", "durationMs": 90000, "maxMs": 90000, "details": str(error)}) + driver.write_json(args.report, report) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/marketplace_fixture.py b/scripts/plugin-host-acceptance/marketplace_fixture.py new file mode 100644 index 0000000000..b020337cd1 --- /dev/null +++ b/scripts/plugin-host-acceptance/marketplace_fixture.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""Deterministic marketplace inputs; TLS and publisher trust stay production-owned. + +Generated files are disposable test inputs under the caller's artifact directory, +never a reusable build cache or installed resource. Native serving requires an +existing certificate trusted for the supplied HTTPS hostname. This module neither +creates certificates nor modifies client trust, DNS, or the production transport. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import socket +import ssl +import threading +import urllib.parse +import urllib.request +import zipfile +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +from generate_packages import DISPLAY_NAME, PLUGIN_ID, write_package + + +PUBLIC_SOURCES = ( + "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/HEAD/Plugins/marketplace/index.json", + "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-store/HEAD/lithe-plugin-index.json", +) +PHASES = ("initial", "update", "bad-digest", "bad-signature") +MAX_INDEX_BYTES = 1024 * 1024 + + +def https_base(value: str) -> str: + parsed = urllib.parse.urlsplit(value) + if (parsed.scheme != "https" or not parsed.hostname or parsed.username is not None + or parsed.password is not None or parsed.query or parsed.fragment + or any(part in (".", "..") for part in parsed.path.split("/"))): + raise ValueError("the fixture needs a credential-free HTTPS base URL without query/fragment") + if parsed.port == 0: + raise ValueError("the public HTTPS endpoint needs a fixed port") + return value.rstrip("/") + + +def json_bytes(value) -> bytes: + return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + + +def entry(base: str, version: str, archive_name: str, digest: str) -> dict: + return { + "id": PLUGIN_ID, "name": DISPLAY_NAME, "author": "Lithe acceptance fixture", + "repository": "https://github.com/example/lithe-marketplace-acceptance", + "description": "Controlled Windows marketplace acceptance fixture", + "tags": ["acceptance", "language"], "license": "MIT", + "versions": [{"version": version, "pluginAPIVersion": 2, + "releaseDate": "2026-01-01", "downloads": [{ + "platforms": ["windows-x64"], + "downloadURL": f"{base}/packages/{archive_name}", "sha256": digest, + }]}], + } + + +def generate_fixture(output: Path, base_url: str, server_source: Path) -> dict: + base = https_base(base_url) + # Never clear an existing directory: it may contain another run's evidence. + output.mkdir(parents=True, exist_ok=False) + packages = output / "packages" + packages.mkdir() + specs = (("a1", "1.0.0", "market-a-1"), ("a2", "1.1.0", "market-a-2"), + ("b9", "9.0.0", "market-b-9"), ("untrusted", "1.2.0", "market-untrusted")) + assets = {} + for name, version, marker in specs: + path = write_package(packages, server_source, name, version, marker) + if name == "untrusted": + # An untrusted publisher document must be rejected even for an + # otherwise unsigned community package; no test key is trusted. + with zipfile.ZipFile(path, "a", compression=zipfile.ZIP_DEFLATED) as archive: + info = zipfile.ZipInfo("lithe-plugin-signature.json", (2026, 1, 1, 0, 0, 0)) + info.external_attr = 0o100644 << 16 + archive.writestr(info, json_bytes({ + "schemaVersion": 1, "algorithm": "ed25519", "keyId": "acceptance-untrusted", + "pluginId": PLUGIN_ID, "pluginVersion": version, "files": {}, + "signature": "AA==", + })) + assets[name] = {"file": f"packages/{name}.zip", "version": version, "marker": marker, + "sha256": hashlib.sha256(path.read_bytes()).hexdigest()} + indexes = {} + for phase, asset_name in zip(PHASES, ("a1", "a2", "a2", "untrusted")): + asset = assets[asset_name] + digest = "0" * 64 if phase == "bad-digest" else asset["sha256"] + indexes[phase] = {"schemaVersion": 1, "plugins": [ + entry(base, asset["version"], Path(asset["file"]).name, digest), + ]} + b = assets["b9"] + plan = {"schemaVersion": 1, "baseURL": base, "pluginId": PLUGIN_ID, + "assets": assets, "indexes": indexes, + "competingIndex": {"schemaVersion": 1, "plugins": [ + entry(base, b["version"], "b9.zip", b["sha256"]), + ]}, + "trust": "unsigned third-party fixture; explicit native approval required; never official"} + (output / "plan.json").write_bytes(json_bytes(plan)) + return plan + + +class RequestGate: + """The test owns arrival/release; a broken scenario cannot hold a server forever.""" + + def __init__(self): + self.arrived = threading.Event() + self.release = threading.Event() + self.finished = threading.Event() + + +class FixtureCatalog: + def __init__(self, root: Path): + self.plan = json.loads((root / "plan.json").read_text(encoding="utf-8")) + if self.plan.get("schemaVersion") != 1 or self.plan.get("pluginId") != PLUGIN_ID: + raise ValueError("unsupported marketplace fixture plan") + self.base = https_base(self.plan["baseURL"]) + self.prefix = urllib.parse.urlsplit(self.base).path + self.assets = {} + for name, asset in self.plan["assets"].items(): + if name not in ("a1", "a2", "b9", "untrusted") or asset["file"] != f"packages/{name}.zip": + raise ValueError("unexpected fixture asset path") + path = root / asset["file"] + if path.is_symlink() or path.parent.is_symlink() or path.parent.is_junction(): + raise ValueError("fixture archives cannot be linked outside the fixture root") + data = path.read_bytes() + if hashlib.sha256(data).hexdigest() != asset["sha256"]: + raise ValueError(f"fixture archive changed: {name}") + self.assets[f"{self.prefix}/{asset['file']}"] = data + if set(self.plan["indexes"]) != set(PHASES): + raise ValueError("the fixture is missing a required index phase") + self.phase = "initial" + self.lock = threading.Lock() + self.gates: dict[str, RequestGate] = {} + self.requests: list[dict] = [] + + def stage(self, phase: str) -> None: + if phase not in PHASES: + raise ValueError(f"unknown fixture phase: {phase}") + with self.lock: + self.phase = phase + + def gate_next_archive(self, name: str = "a1") -> RequestGate: + path = f"{self.prefix}/packages/{name}.zip" + with self.lock: + if path not in self.assets or path in self.gates: + raise ValueError("unknown or already gated archive") + gate = RequestGate() + self.gates[path] = gate + return gate + + def response(self, path: str): + with self.lock: + self.requests.append({"path": path, "phase": self.phase}) + gate = self.gates.pop(path, None) + if path == f"{self.prefix}/source-a/index.json": + return json_bytes(self.plan["indexes"][self.phase]), "application/json", gate + if path == f"{self.prefix}/source-b/index.json": + return json_bytes(self.plan["competingIndex"]), "application/json", gate + return self.assets.get(path), "application/zip", gate + + +class FixtureServer: + """Own one bounded listener thread. HTTP without TLS is only used by unit tests. + + Native callers must use `native`; it has no HTTP mode, extra client roots, + certificate-ignore option, or public control endpoint. Staging and gates are + local Python objects, so downloaded content cannot control the test host. + """ + + def __init__(self, catalog: FixtureCatalog, address, tls_context: ssl.SSLContext | None): + self.catalog = catalog + self.stop = threading.Event() + self.active_socket = None + self.active_gate = None + self.errors = [] + owner = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + data, content_type, gate = catalog.response(self.path) + if data is None: + self.send_error(404) + return + owner.active_gate = gate + try: + if gate is not None: + gate.arrived.set() + if not gate.release.wait(timeout=30): + self.send_error(504, "fixture gate exceeded its local deadline") + return + if owner.stop.is_set(): + return + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(data) + except (ConnectionError, OSError): + # Cancellation deliberately closes the native download. + pass + finally: + if gate is not None: + gate.finished.set() + owner.active_gate = None + + def log_message(self, _format, *_args): + pass + + class Server(HTTPServer): + def get_request(self): + connection, client = super().get_request() + connection.settimeout(5) + owner.active_socket = connection + try: + if tls_context is not None: + connection = tls_context.wrap_socket(connection, server_side=True, + do_handshake_on_connect=False) + owner.active_socket = connection + connection.do_handshake() + return connection, client + except Exception: + connection.close() + owner.active_socket = None + raise + + def handle_error(self, _request, _client): + owner.errors.append("fixture request handler failed") + + def close_request(self, request): + super().close_request(request) + owner.active_socket = None + + self.server = Server(address, Handler) + self.server.timeout = 0.1 + self.thread = threading.Thread(target=self._run, name="marketplace-fixture-server") + + @classmethod + def native(cls, catalog: FixtureCatalog, cert: Path, key: Path, listen_host: str): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.load_cert_chain(certfile=cert, keyfile=key) + port = urllib.parse.urlsplit(catalog.base).port or 443 + return cls(catalog, (listen_host, port), context) + + def _run(self): + while not self.stop.is_set(): + self.server.handle_request() + + def __enter__(self): + self.thread.start() + return self + + def __exit__(self, *_args): + self.stop.set() + if self.active_gate is not None: + self.active_gate.release.set() + connection = self.active_socket + if connection is not None: + try: + connection.shutdown(socket.SHUT_RDWR) + except OSError: + pass + self.thread.join(timeout=6) + self.server.server_close() + if self.thread.is_alive(): + raise RuntimeError("the fixture listener survived its six-second cleanup deadline") + if self.errors: + raise RuntimeError("; ".join(self.errors)) + + +class HttpsRedirects(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, url): + https_base(url) + return super().redirect_request(request, response, code, message, headers, url) + + +def probe_public_sources() -> dict: + """Network inventory only; neither Core validation nor native acceptance.""" + opener = urllib.request.build_opener(HttpsRedirects()) + results = [] + for url in PUBLIC_SOURCES: + result = {"url": url, "nativeAcceptance": False} + try: + with opener.open(url, timeout=10) as response: + data = response.read(MAX_INDEX_BYTES + 1) + if len(data) > MAX_INDEX_BYTES: + raise ValueError("public index exceeds the probe's byte limit") + document = json.loads(data) + windows = [] + for plugin in document["plugins"]: + for version in plugin.get("versions", []): + for download in version.get("downloads", []): + if "windows-x64" in download.get("platforms", []): + windows.append({"id": plugin["id"], "version": version["version"]}) + result.update(sha256=hashlib.sha256(data).hexdigest(), + pluginCount=len(document["plugins"]), windowsX64Downloads=windows) + except Exception as error: + result["error"] = f"{type(error).__name__}: {error}" + results.append(result) + return {"kind": "HTTPS index inventory; no product was launched", "sources": results} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + generate = sub.add_parser("generate") + generate.add_argument("--output", required=True, type=Path) + generate.add_argument("--base-url", required=True) + generate.add_argument("--server", type=Path, default=Path(__file__).with_name("fake-lsp-server.mjs")) + probe = sub.add_parser("probe-public") + probe.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + if args.command == "generate": + generate_fixture(args.output.resolve(), args.base_url, args.server.resolve()) + print(f"Fixture prepared: {args.output.resolve() / 'plan.json'}; native execution remains pending") + else: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_bytes(json_bytes(probe_public_sources())) + print(f"Public HTTPS inventory: {args.output.resolve()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/native_browser_supervisor.py b/scripts/plugin-host-acceptance/native_browser_supervisor.py new file mode 100644 index 0000000000..4c5fee4658 --- /dev/null +++ b/scripts/plugin-host-acceptance/native_browser_supervisor.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Own the controlled browser worker through the existing gated Windows Job.""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys +import threading +import time + +from vcs_move_diagnostic import WindowsJob, supervised + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--timeout-ms", required=True, type=int) + parser.add_argument("--result", required=True, type=Path) + parser.add_argument("--log", required=True, type=Path) + parser.add_argument("--force-owner-exit-on", type=Path) + parser.add_argument("command", nargs=argparse.REMAINDER) + args = parser.parse_args() + command = args.command[1:] if args.command[:1] == ["--"] else args.command + if sys.platform != "win32" or not command or not 1 <= args.timeout_ms <= 600_000: + parser.error("requires Windows, an explicit command and a 1..600000ms timeout") + stop = threading.Event() + watcher = None + if args.force_owner_exit_on is not None: + def forced_exit(): + deadline = time.monotonic() + args.timeout_ms / 1000 + # Controlled lifecycle fault: kill-on-job-close must clean descendants + # even when this owner cannot publish its ordinary cleanup proof. + while not stop.is_set() and time.monotonic() < deadline: + if args.force_owner_exit_on.is_file(): + os._exit(37) + stop.wait(0.01) + watcher = threading.Thread(target=forced_exit, daemon=True) + watcher.start() + try: + # WindowsJob creates a trusted Python bootstrap, assigns its native + # handle, then sends release. Node cannot spawn a driver before assignment. + result = supervised(command, args.timeout_ms / 1000, args.log, WindowsJob) + temporary = args.result.with_suffix(".tmp") + temporary.write_text(json.dumps(result, indent=2), encoding="utf-8") + temporary.replace(args.result) + return 0 if result["status"] == "passed" else 1 + finally: + stop.set() + if watcher is not None: + watcher.join(timeout=1) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/settings_functions_acceptance.py b/scripts/plugin-host-acceptance/settings_functions_acceptance.py new file mode 100644 index 0000000000..0737531a1f --- /dev/null +++ b/scripts/plugin-host-acceptance/settings_functions_acceptance.py @@ -0,0 +1,628 @@ +#!/usr/bin/env python3 +"""M10 phase-one settings entry point using the existing native acceptance owner. + +Preflight never launches a product. Controlled settings evidence and blocked +real SDK/package evidence are separate report rows. Incomplete M10 exits nonzero; +source presence, mocked helpers and a successful settings subset cannot pass it. +""" +from __future__ import annotations + +import argparse +import json +import platform +import re +import shutil +import stat +import sys +import time +from pathlib import Path + +from build_acceptance import missing_capabilities, source_capabilities +from build_fixture import ROOT, confined +from settings_functions_fixture import DISPLAY_NAME, LOCK, PLUGIN_ID, load_plan, sha256 + +SUITE = "plugin-host-acceptance / settings-functions" +SETTINGS_CASES = ("trusted-install-review", "public-search-clear-default-activation", "explicit-save-empty-reset-CAS", "two-window-isolation", + "project-reload-receipt", "global-reload", "upgrade-disable-uninstall", "observer-cleanup", "resource-invariance") +FUNCTION_CASES = ("discovery-approval-result", "progress-cancel-plugin-error", "settings-and-web-production-entry", + "two-window-workspace-isolation", "reload-late-callback", "cleanup-failure-retry", + "upgrade-disable-cleanup") + + +def pending_cases(plan: dict, capabilities: dict) -> list[dict]: + lock = json.loads(LOCK.read_text(encoding="utf-8")) + settings_missing = missing_capabilities(plan["requires"], capabilities) + function_missing = missing_capabilities(lock["requires"], capabilities) + rows = [] + for category, names in (("controlled-settings", SETTINGS_CASES), ("real-functions", FUNCTION_CASES)): + missing = settings_missing if category == "controlled-settings" else function_missing + reason = "Not executed; normal isolated product install and approval required." + if missing: + reason += " Closed complete-package prerequisites: " + ", ".join(missing) + "." + if category == "real-functions": + reason += " " + " ".join(lock["audit"]["blockers"]) + for name in names: + rows.append({"name": f"{category} / {name}", "suite": SUITE, "status": "skipped", + "durationMs": 0, "maxMs": 120_000, "details": reason}) + for name, reason in (("windows-arm64", "No native arm64 execution environment."), + ("manual-visual", "Real keyboard, IME, narrow-window and theme checks remain pending.")): + rows.append({"name": name, "suite": SUITE, "status": "skipped", "durationMs": 0, + "maxMs": 120_000, "details": reason}) + return rows + + +def record_unreached_cases(reporter, plan: dict) -> None: + completed = {row["name"] for row in reporter.tests} + reporter.tests.extend(row for row in pending_cases(plan, source_capabilities()) if row["name"] not in completed) + + +def add_arguments(parser) -> None: + parser.add_argument("--settings-functions-fixture-root", type=Path) + parser.add_argument("--settings-functions-host-identity", type=Path, + help="Existing isolated build-identity.json; executable bytes and Git revision must match.") + + +def validate_arguments(parser, args) -> None: + if bool(args.settings_functions_fixture_root) != bool(args.settings_functions_host_identity): + parser.error("settings/functions requires both --settings-functions-fixture-root and --settings-functions-host-identity") + if args.settings_functions_fixture_root and (args.marketplace or args.lsp_lifecycle or args.build_fixture_root): + parser.error("settings/functions requires a focused invocation; run marketplace/LSP/build separately") + + +def snapshot_resources(root: Path, *, max_files=100_000, max_bytes=8 * 1024**3) -> dict: + """Bounded exact listing, including empty directories; never follow reparse points.""" + for parent in (root, *root.parents): + info = parent.lstat() + if stat.S_ISLNK(info.st_mode) or getattr(info, "st_file_attributes", 0) & 0x400: + raise ValueError("resource path traverses a link/reparse point") + root = root.resolve(strict=True) + result, total = {}, 0 + directories = [root] + while directories: + for path in sorted(directories.pop().iterdir()): + info = path.lstat() + if stat.S_ISLNK(info.st_mode) or getattr(info, "st_file_attributes", 0) & 0x400: + raise ValueError("resource snapshot encountered a link/reparse point") + key = path.relative_to(root).as_posix() + if stat.S_ISDIR(info.st_mode): + result[key + "/"] = {"kind": "directory"} + directories.append(path) + elif stat.S_ISREG(info.st_mode): + total += info.st_size + if total > max_bytes: + raise ValueError("resource snapshot byte budget exceeded") + result[key] = {"kind": "file", "bytes": info.st_size, "sha256": sha256(path)} + else: + raise ValueError("resource snapshot encountered a special file") + if len(result) > max_files: + raise ValueError("resource snapshot entry budget exceeded") + return result + + +def resource_diff(before: dict, after: dict) -> dict: + return {"added": sorted(after.keys() - before.keys()), "removed": sorted(before.keys() - after.keys()), + "changed": sorted(key for key in before.keys() & after.keys() if before[key] != after[key])} + + +def assert_resources(before: dict, after: dict) -> dict: + difference = resource_diff(before, after) + if any(difference.values()): + raise AssertionError("read-only resource mutation: " + json.dumps(difference, sort_keys=True)) + return difference + + +def wait_for(predicate, description, *, now=time.monotonic, pump, seconds=10): + # Pump the existing Playwright dispatcher. Unit checks inject a manual clock; + # no real sleep, detached task or second process/JSON-RPC runner is introduced. + deadline = now() + seconds + while now() < deadline: + value = predicate() + if value: + return value + pump() + raise TimeoutError(f"settings/functions deadline: {description}") + + +def verify_installed_archive(package_root: Path, archive: Path, evidence: Path) -> None: + """Compare the whole installed tree with bounded, original fixture ZIP bytes.""" + import hashlib + import zipfile + expected, total = {}, 0 + with zipfile.ZipFile(archive) as package: + entries = package.infolist() + if len(entries) > 1000: + raise ValueError("installed fixture archive entry budget exceeded") + for entry in entries: + name = entry.filename + if name.startswith("/") or "\\" in name or any(part in ("", ".", "..") for part in name.rstrip("/").split("/")): + raise ValueError("installed fixture archive has an unsafe path") + total += entry.file_size + if total > 64 * 1024**2: + raise ValueError("installed fixture archive byte budget exceeded") + for parent in Path(name).parents: + if parent.as_posix() != ".": + expected[parent.as_posix() + "/"] = {"kind": "directory"} + if entry.is_dir(): + expected[name] = {"kind": "directory"} + else: + with package.open(entry) as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + expected[name] = {"kind": "file", "bytes": entry.file_size, "sha256": digest} + actual = snapshot_resources(package_root) + evidence.parent.mkdir(parents=True, exist_ok=True) + # Save the actual mutation before raising; a later checkpoint cannot hide it. + evidence.write_text(json.dumps({"archiveSha256": sha256(archive), "expected": expected, + "actual": actual, "diff": resource_diff(expected, actual)}, indent=2) + "\n", encoding="utf-8") + assert_resources(expected, actual) + + +def verify_host(identity_path: Path, executable: Path, plan: dict) -> dict: + identity = json.loads(identity_path.read_text(encoding="utf-8-sig")) + if (identity.get("identifier") != "app.lithe.windows.host-acceptance" + or identity.get("rustTarget") != "x86_64-pc-windows-msvc" + or identity.get("gitRevision") != plan["sourceCommit"] + or identity.get("executableSha256") != sha256(executable)): + raise ValueError("host executable, architecture or source identity does not match the selected fixture/build") + return identity + + +class SettingsFunctionsAcceptance: + """Owned by acceptance_driver.AppSession; no independent launch/IPC truth.""" + def __init__(self, driver, args, reporter, restart, stop): + self.driver, self.args, self.reporter, self.restart = driver, args, reporter, restart + self.stop = stop + self.root = args.settings_functions_fixture_root + self.plan = load_plan(self.root) + self.root = self.root.resolve() + self.evidence = args.artifact_root / "settings-functions" + self.evidence.mkdir(parents=True, exist_ok=True) + self.page = self.session = self.second = None + self.workspace = confined(self.root, "one") + self.package_root = None + self.package_baselines = {} + + def native(self, page, command, args=None): + # All calls retain the existing 10-second IPC deadline. + return self.driver.invoke(page, command, args) + + def step(self, name): + return self.reporter.step("controlled-settings / " + name) + + def catalog(self, page): + return next((row for row in self.native(page, "declarative_plugins_state") + if row["pluginId"] == PLUGIN_ID and row["source"] == "installed"), None) + + def details(self, page): + self.driver.open_plugin_settings(page) + row = page.locator(f"[data-plugin-id='{PLUGIN_ID}'][data-plugin-source='installed']") + row.wait_for(state="visible", timeout=15_000) + row.get_by_role("button").click(timeout=10_000) + details = page.locator("[data-settings-content='declarative-plugins']").get_by_role("region", name=DISPLAY_NAME, exact=True) + details.wait_for(state="visible", timeout=15_000) + return details + + def editor(self, page, *, registered=False): + if registered: + self.driver.open_plugin_settings(page) + group = page.locator("[aria-controls='settings-group-languages']") + if group.get_attribute("aria-expanded", timeout=10_000) != "true": + group.click(timeout=10_000) + route = "plugin-settings:language:lithesettingsaccept" + category = page.locator(f"[data-settings-category='{route}']") + category.wait_for(state="visible", timeout=15_000) + category.click(timeout=10_000) + else: + self.details(page).get_by_role("tab", name=re.compile(r"^Settings(?:\s|\d|$)")).click(timeout=10_000) + route = "declarative-plugins" + # The dialog retains visited pages. Scope to the selected content so a + # hidden detail editor cannot satisfy the registered-page workflow. + content = page.locator(f"[data-settings-content='{route}']") + content.wait_for(state="visible", timeout=15_000) + editor = content.locator(f"[data-plugin-settings='{PLUGIN_ID}']") + setting = "reload" if registered else "message" + editor.locator(f"[data-plugin-setting='{setting}']").wait_for(state="visible", timeout=15_000) + return editor + + def snapshot(self, page, workspace): + return self.native(page, "declarative_plugins_setting_snapshot", {"pluginId": PLUGIN_ID, "workspacePath": str(workspace)}) + + def wait(self, predicate, description): + return wait_for(predicate, description, pump=lambda: self.page.evaluate("() => true")) + + def save(self, page, setting, value, layer="global", *, registered=False): + editor = self.editor(page, registered=registered) + select = editor.get_by_role("combobox") + select.click(timeout=10_000) + page.get_by_role("option", name="this workspace" if layer == "project" else "all workspaces", exact=True).click(timeout=10_000) + row = editor.locator(f"[data-plugin-setting='{setting}']") + row.get_by_role("textbox").fill(value, timeout=10_000) + offset = page.evaluate("() => window.__litheSettingsAcceptance.calls.length") + row.get_by_role("button", name=re.compile(r": Save$")).click(timeout=10_000) + call = self.wait(lambda: page.evaluate("""([offset,id,scope]) => window.__litheSettingsAcceptance.calls.slice(offset).find(c => + c.command === 'declarative_plugins_store_setting' && c.args.settingId === id && c.args.scope === scope && c.settled)""", + [offset, setting, layer]), "explicit save settled") + if call.get("error"): + raise AssertionError(f"settings save failed: {call['error']}") + if editor.get_by_role("alert").count(): + raise AssertionError(editor.get_by_role("alert").inner_text(timeout=5000)) + return call["value"] + + def compare_package(self, name): + for version, before in self.package_baselines.items(): + package_root = self.args.app_local_data / "plugins" / PLUGIN_ID / version + after = snapshot_resources(package_root) + self.driver.write_json(self.evidence / f"{name}-{version}-package-after.json", after) + difference = resource_diff(before, after) + self.driver.write_json(self.evidence / f"{name}-{version}-package-diff.json", difference) + assert_resources(before, after) + + def install(self, version): + asset = self.plan["archives"][version] + review = self.driver.import_review(self.page, self.session.process.pid, confined(self.root, asset["path"])) + review_text = review.inner_text(timeout=5000) + self.driver.write_json(self.evidence / f"{version}-review.json", {"text": review_text, "archive": asset}) + if "Unverified publisher" not in review_text: + raise AssertionError("the controlled fixture was not shown as an unverified publisher") + if "languageServer.launch" not in review_text: + raise AssertionError("the controlled language server permission was not disclosed") + review.get_by_role("button", name=re.compile(r"Program declaration \(\d+\)")).click(timeout=10_000) + programs = json.loads(review.locator("pre").inner_text(timeout=5000)) + if (len(programs) != 2 or programs[0] != {"kind": "runtime", "tool": "node"} + or programs[1] != {"kind": "runtimeLanguageServer", "tool": "node", "packagedRuntime": None, + "entry": "server/fake-lsp-server.mjs", "runtimeArguments": [], "validationArguments": [], + "arguments": ["--stdio", "--state-dir", "${workspaceStateDir}", "--fixture-version", version], + "languageID": "lithesettingsaccept"}): + raise AssertionError("the controlled language server declaration changed") + self.driver.confirm_review(self.page, review) + self.wait(lambda: (row if (row := self.catalog(self.page)) and row["status"] == "accepted" + and row["version"] == version and row["enabled"] else None), "accepted complete package") + record = json.loads((self.args.app_local_data / "plugins" / PLUGIN_ID / "installation.json").read_text(encoding="utf-8")) + if (record["disk"]["archiveSha256"] != asset["sha256"] + or [json.loads(program) for program in record["disk"]["approvedPrograms"]] != programs): + raise AssertionError("installed approval does not match the complete controlled archive") + self.driver.write_json(self.evidence / f"{version}-installation.json", record) + self.package_root = self.args.app_local_data / "plugins" / PLUGIN_ID / version + before = snapshot_resources(self.package_root) + self.package_baselines[version] = before + self.driver.write_json(self.evidence / f"{version}-package-before.json", before) + + def public_search_clear(self): + # This is a real WebView2 workflow. DOM evaluation only observes state; + # the browser's input channel owns text and default button activation. + self.details(self.page) # Explicitly select the installed fixture row. + content = self.page.locator("[data-settings-content='declarative-plugins']") + search = content.get_by_role("textbox", name="Search name or ID; / for filters", exact=True) + search.wait_for(state="visible", timeout=10_000) + handle = search.element_handle(timeout=10_000) + evidence = {"inputCommands": [], "snapshots": {}, "boundary": + "Real WebView2/CDP browser input; not physical keyboard or IME. No synthetic events or activation click."} + + def snapshot(): + return handle.evaluate(r"""input => { + const content=input.closest('[data-settings-content]'); + const rows=[...content.querySelectorAll('[data-testid="declarative-plugin-list"] [data-plugin-id]')]; + const identity=row=>({pluginId:row.dataset.pluginId,source:row.dataset.pluginSource}); + const active=document.activeElement; + return {value:input.value,connected:input.isConnected,activeIsSameInput:active===input, + activeElement:{tag:active?.tagName,label:active?.getAttribute('aria-label')}, + rows:rows.map(identity),selected:rows.filter(row=>row.querySelector('[aria-pressed="true"]')).map(identity), + count:content.querySelector('[role="status"]')?.textContent, + clearButtons:content.querySelectorAll('button[aria-label="Clear search"]').length, + noMatches:content.textContent.includes('No plugins match these filters.'), + hiddenSelection:content.textContent.includes('The selected plugin is hidden by the current filters.'), + selectedDetails:[...content.querySelectorAll('section[aria-label], [role="region"]')].map(region=>region.getAttribute('aria-label'))}; + }""") + + def capture(name): + state = snapshot() + evidence["snapshots"][name] = state + return state + + def input_command(method, value): + record = {"method": "page.keyboard." + method, "value": value, "completed": False} + evidence["inputCommands"].append(record) + getattr(self.page.keyboard, method)(value) + record["completed"] = True + + try: + offset = self.page.evaluate("() => window.__litheSettingsAcceptance.calls.length") + search.focus(timeout=10_000) + before = capture("before") + if (before["value"] or not before["rows"] or before["clearButtons"] + or before["selected"] != [{"pluginId": PLUGIN_ID, "source": "installed"}] + or DISPLAY_NAME not in before["selectedDetails"] + or before["count"] != f"Showing {len(before['rows'])} of {len(before['rows'])} plugins"): + raise AssertionError("search baseline must retain the explicitly selected installed row and unfiltered count") + for key, sentinel in (("Enter", "lithe-r17-no-match-enter-7d91"), ("Space", "lithe-r17-no-match-space-83b2")): + # The second insert deliberately uses the focus returned by Enter; + # no fill/focus between the restoration and this input is allowed. + input_command("insert_text", sentinel) + self.wait(lambda: snapshot()["value"] == sentinel and not snapshot()["rows"], f"{key} no-match filter") + filtered = capture(key + "-filtered") + if (not filtered["activeIsSameInput"] or not filtered["noMatches"] or not filtered["hiddenSelection"] + or filtered["clearButtons"] != 1 or filtered["count"] != f"Showing 0 of {len(before['rows'])} plugins" + or filtered["selectedDetails"] != before["selectedDetails"]): + raise AssertionError("filter lost focus, no-match/count or hidden selection evidence") + clear = content.get_by_role("button", name="Clear search", exact=True) + clear.focus(timeout=10_000) + focused = capture(key + "-button-focused") + if focused["activeElement"] != {"tag": "BUTTON", "label": "Clear search"}: + raise AssertionError("real clear button is not focused") + try: + input_command("down", key) + finally: + input_command("up", key) + self.wait(lambda: snapshot()["value"] == "" and snapshot()["clearButtons"] == 0, f"{key} browser default clear") + restored = capture(key + "-restored") + if (not restored["connected"] or not restored["activeIsSameInput"] or restored["noMatches"] + or restored["hiddenSelection"] or any(restored[field] != before[field] + for field in ("rows", "count", "selected", "selectedDetails"))): + raise AssertionError(f"{key} did not restore rows/count/selection and focus to the same input") + calls = self.page.evaluate("offset => window.__litheSettingsAcceptance.calls.slice(offset)", offset) + evidence["observedCallsDuringSearch"] = calls + if calls: + raise AssertionError("local search unexpectedly sent declarative plugin IPC") + evidence["passed"] = True + finally: + try: + self.driver.write_json(self.evidence / "public-search-clear-default-activation.json", evidence) + finally: + handle.dispose() + + def explicit_save(self): + before = self.snapshot(self.page, self.workspace) + self.editor(self.page) + if self.snapshot(self.page, self.workspace) != before: + raise AssertionError("opening settings mutated its snapshot") + self.save(self.page, "message", "global-value") + saved = self.snapshot(self.page, self.workspace) + if saved["globalValues"].get("message") != "global-value": + raise AssertionError("UI save did not persist the global value") + # A real concurrent writer uses the production command; stale CAS must + # reject without clobbering either store. Never edit the store on disk. + cas_error = None + try: + self.native(self.page, "declarative_plugins_store_setting", {"pluginId": PLUGIN_ID, "settingId": "message", + "scope": "global", "workspacePath": str(self.workspace), "value": "lost-update", "expectedIdentity": before["identity"]}) + except Exception as error: + if "plugin.settings.conflict" not in str(error): + raise + cas_error = str(error) + else: + raise AssertionError("stale settings CAS was accepted") + if self.snapshot(self.page, self.workspace) != saved: + raise AssertionError("failed CAS changed settings") + self.save(self.page, "message", "", "project") + empty = self.snapshot(self.page, self.workspace) + if empty["projectValues"].get("message") != "": + raise AssertionError("explicit empty project value was lost") + editor = self.editor(self.page) + row = editor.locator("[data-plugin-setting='message']") + # Select the intended layer explicitly when revisiting the editor. + row_scope = editor.get_by_role("combobox") + row_scope.click(timeout=10_000) + self.page.get_by_role("option", name="this workspace", exact=True).click(timeout=10_000) + row.get_by_role("button", name=re.compile(r": Use inherited value$")).click(timeout=10_000) + self.wait(lambda: "message" not in self.snapshot(self.page, self.workspace)["projectValues"], "project reset") + self.driver.write_json(self.evidence / "explicit-save-empty-reset-CAS.json", { + "before": before, "saved": saved, "afterReset": self.snapshot(self.page, self.workspace), + "staleCasError": cas_error, "explicitEmptySnapshot": empty, + }) + self.compare_package("explicit-save") + + def two_windows(self): + with self.page.context.expect_page(timeout=30_000) as new_page: + label = self.native(self.page, "create_app_window", {"request": {"type": "directory", "path": str(confined(self.root, "two"))}}) + self.second = new_page.value + self.second.wait_for_load_state("domcontentloaded", timeout=15_000) + self.second.wait_for_function("() => Boolean(window.__TAURI_INTERNALS__?.invoke)", timeout=15_000) + self.driver.finish_onboarding(self.second) + self.second.evaluate(OBSERVER) + first_receipt = self.save(self.page, "message", "window-one", "project") + second_receipt = self.save(self.second, "message", "window-two", "project") + first_context, second_context = first_receipt.get("reloadContext"), second_receipt.get("reloadContext") + if (not first_context or not second_context + or first_context["windowIncarnation"] == second_context["windowIncarnation"] + or first_context["workspaceId"] == second_context["workspaceId"]): + raise AssertionError("two product windows lack distinct native window/workspace receipts") + first, second = self.snapshot(self.page, self.workspace), self.snapshot(self.second, confined(self.root, "two")) + if first["projectValues"].get("message") != "window-one" or second["projectValues"].get("message") != "window-two": + raise AssertionError("real product windows leaked workspace settings") + self.driver.write_json(self.evidence / "two-windows.json", {"secondLabel": label, "first": first, "second": second, + "firstContext": first_context, "secondContext": second_context}) + + def reload(self, scope): + self.save(self.page, "reload", f"{scope}-reload", scope, registered=True) + notice = self.page.locator(f"[data-plugin-reload='{PLUGIN_ID}'][data-plugin-reload-scope='{scope}']") + notice.wait_for(state="visible", timeout=10_000) + notice.locator("[data-plugin-reload-confirm]").click(timeout=10_000) + # A vanished notice alone is insufficient: the read-only observer must + # see a settled real production call with a native snapshot receipt. + call = self.wait(lambda: self.page.evaluate("""scope => window.__litheSettingsAcceptance.calls.findLast(c => + c.command === 'declarative_plugins_reload_settings' && c.args.scope === scope && c.settled)""", scope), "native reload result") + if call.get("error") or not call.get("value"): + raise AssertionError(f"native reload failed: {call}") + args = call["args"] + if not args.get("expectedIdentity") or not args.get("expectedPackageIdentity") or (scope == "project" and not args.get("project")): + raise AssertionError("reload did not consume the actual settings/package/project receipt") + notice.wait_for(state="detached", timeout=15_000) + if self.snapshot(self.second, confined(self.root, "two"))["projectValues"].get("message") != "window-two": + raise AssertionError("reload changed another workspace's settings") + self.driver.write_json(self.evidence / f"{scope}-reload.json", call) + self.compare_package(scope + "-reload") + + def lifecycle(self): + self.compare_package("before-upgrade") + self.install("1.1.0") + self.compare_package("after-upgrade") + self.details(self.page).get_by_role("button", name="Disable", exact=True).click(timeout=10_000) + self.wait(lambda: not self.catalog(self.page)["enabled"], "disable") + self.compare_package("disabled") + self.details(self.page).get_by_role("button", name="Enable", exact=True).click(timeout=10_000) + self.wait(lambda: self.catalog(self.page)["enabled"], "enable") + self.details(self.page).get_by_role("button", name="Uninstall", exact=True).click(timeout=10_000) + self.wait(lambda: self.catalog(self.page) is None, "uninstall") + if (self.args.app_local_data / "plugins" / PLUGIN_ID).exists() or (self.args.app_local_data / "plugin-state" / PLUGIN_ID).exists(): + raise AssertionError("uninstall retained package/state") + self.driver.write_json(self.evidence / "lifecycle.json", {"upgradedTo": "1.1.0", "disabled": True, + "enabledAgain": True, "uninstalled": True, "packageAndStateRemoved": True}) + + def stop_observers(self): + errors = [] + for index, page in enumerate((self.page, self.second)): + if page is None: + continue + try: + observed = page.evaluate("() => window.__litheSettingsAcceptance?.snapshot()") + self.driver.write_json(self.evidence / f"observed-calls-{index}.json", observed) + if observed and observed["dropped"]: + raise AssertionError("acceptance observer truncated its evidence") + except Exception as error: + errors.append(f"window {index} snapshot: {error}") + finally: + try: + final = page.evaluate("() => window.__litheSettingsAcceptance.stop()") + self.driver.write_json(self.evidence / f"observed-calls-{index}.json", final) + if not final or final.get("originalInvokeRestored") is not True: + raise AssertionError("IPC observer did not restore the original native invoke") + if final.get("pending") or any(not call.get("settled") for call in final["calls"]): + raise AssertionError("IPC observer deadline exceeded with unsettled native calls") + if final["dropped"]: + raise AssertionError("acceptance observer truncated its evidence") + self.driver.write_json(self.evidence / f"observer-cleanup-{index}.json", {"originalInvokeRestored": True, "pending": 0}) + except Exception as error: + errors.append(f"window {index} observer cleanup: {error}") + if errors: + raise RuntimeError("; ".join(errors)) + + def run(self): + resources_before = snapshot_resources(self.args.run_image) + self.driver.write_json(self.evidence / "resources-before.json", resources_before) + shutdown_completed = False + try: + with self.step("trusted-install-review"): + identity = verify_host(self.args.settings_functions_host_identity, self.args.executable, self.plan) + if missing_capabilities(self.plan["requires"], source_capabilities()): + raise RuntimeError("controlled settings complete package requires closed host capabilities") + self.page, self.session = self.restart(self.workspace) + self.page.evaluate(OBSERVER) + node = Path(shutil.which("node") or "missing-node").resolve(strict=True) + node_version = self.driver.run_bounded_process([str(node), "--version"], 10) + if node_version.returncode: + raise RuntimeError("could not establish the native Node tool identity") + self.driver.write_json(self.evidence / "identity.json", {"host": identity, "fixture": self.plan, + "architecture": platform.machine(), "python": {"version": sys.version, "executableSha256": sha256(Path(sys.executable))}, + "node": {"version": node_version.stdout.strip(), "executableSha256": sha256(node)}, + "sdkExecuted": False}) + self.install("1.0.0") + with self.step("public-search-clear-default-activation"): + self.public_search_clear() + with self.step("explicit-save-empty-reset-CAS"): + self.explicit_save() + with self.step("two-window-isolation"): + self.two_windows() + with self.step("project-reload-receipt"): + self.reload("project") + with self.step("global-reload"): + self.reload("global") + with self.step("upgrade-disable-uninstall"): + self.lifecycle() + finally: + try: + with self.step("observer-cleanup"): + try: + self.stop_observers() + finally: + # The native owner retains handles through graceful quit. + # Resource invariance is checked after its final writers exit. + self.stop() + shutdown_completed = True + finally: + with self.step("resource-invariance"): + after = snapshot_resources(self.args.run_image) + self.driver.write_json(self.evidence / "resources-after.json", after) + self.driver.write_json(self.evidence / "resources-diff.json", resource_diff(resources_before, after)) + assert_resources(resources_before, after) + if self.package_root is not None and self.package_root.exists(): + self.compare_package("final") + if not shutdown_completed: + raise AssertionError("native shutdown did not complete before final resource snapshot") + record_unreached_cases(self.reporter, self.plan) + # No signed compatible real-functions archive/entry exists at this + # frozen baseline. A successful controlled subset must still fail M10. + + +# Transparent IPC observation only. Original results/errors pass unchanged; +# the window's production frontend owns calls, approvals and effect dispatch. +OBSERVER = r"""() => { + 'use strict'; + if (window.__litheSettingsAcceptance) throw new Error('observer already active'); + const internals=window.__TAURI_INTERNALS__, original=internals.invoke, originalFetch=window.fetch; + const state={calls:[],dropped:0}, pending=new Set(); + // Tauri 2 freezes invoke/ipc. Observe the pinned Windows fetch transport and + // its real callback Map instead; never replace the native command or result. + const observed=function(input,options) { + let url; + try {url=new URL(typeof input==='string'?input:input.url ?? String(input),window.location.href);} + catch {return originalFetch.apply(this,arguments);} + if(url.hostname==='ipc.localhost' && decodeURIComponent(url.pathname.slice(1)).startsWith('declarative_plugins_')) { + const call={command:decodeURIComponent(url.pathname.slice(1)),args:JSON.parse(options.body),settled:false}; + if(state.calls.length<2048) state.calls.push(call); else state.dropped++; + let finish; + const completion=new Promise(resolve=>{finish=resolve;}); + pending.add(completion); + for(const [header,key] of [['Tauri-Callback','value'],['Tauri-Error','error']]) { + const id=Number(options.headers.get(header)), callback=internals.callbacks.get(id); + if(typeof callback!=='function') throw new Error('native IPC callback unavailable'); + internals.callbacks.set(id,function(value) { + call.settled=true;call[key]=key==='error'?String(value):value; + pending.delete(completion);finish(); + return callback.apply(this,arguments); + }); + } + } + return originalFetch.apply(this,arguments); + }; + window.fetch=observed; + if(window.fetch!==observed) throw new Error('native fetch observation unavailable'); + state.snapshot=()=>({calls:state.calls,dropped:state.dropped}); + state.stop=async()=>{ + if(window.fetch!==observed || internals.invoke!==original) throw new Error('native invoke changed while observer was active'); + // Restore admission first; already admitted callback wrappers forward late + // replies and remain owned until their settlement or the local deadline. + window.fetch=originalFetch; + let deadline; + try { + await Promise.race([Promise.allSettled([...pending]), + new Promise(resolve=>{deadline=setTimeout(resolve,10000);})]); + } finally {clearTimeout(deadline);} + delete window.__litheSettingsAcceptance; + return {...state.snapshot(),pending:pending.size, + originalInvokeRestored:internals.invoke===original && window.fetch===originalFetch && !window.__litheSettingsAcceptance}; + }; + window.__litheSettingsAcceptance=state; +}""" + + +def main() -> int: + parser = argparse.ArgumentParser(description="M10 preflight only; never starts an application.") + parser.add_argument("--fixture-root", required=True, type=Path) + parser.add_argument("--report", required=True, type=Path) + args = parser.parse_args() + report = {"runner": "acceptance", "warnMs": 30_000, "maxMs": 120_000, "complete": False, "tests": []} + try: + plan = load_plan(args.fixture_root) + report.update(fixture=plan, hostCapabilities=source_capabilities(), tests=pending_cases(plan, source_capabilities())) + code = 2 + except Exception as error: + report["tests"].append({"name": "settings/functions preflight", "suite": SUITE, "status": "failed", + "durationMs": 0, "maxMs": 10_000, "details": f"{type(error).__name__}: {error}"}) + code = 1 + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + print("M10 pending; no product executed." if code == 2 else "M10 preflight failed.") + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/settings_functions_acceptance_test.py b/scripts/plugin-host-acceptance/settings_functions_acceptance_test.py new file mode 100644 index 0000000000..94a8a7a653 --- /dev/null +++ b/scripts/plugin-host-acceptance/settings_functions_acceptance_test.py @@ -0,0 +1,498 @@ +#!/usr/bin/env python3 +"""Deterministic driver self-checks, explicitly separate from product acceptance.""" +from __future__ import annotations + +import argparse +import contextlib +import io +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +import unittest +import zipfile +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import settings_functions_acceptance as acceptance +import settings_functions_fixture as fixture + + +class SettingsFunctionsHelperTests(unittest.TestCase): + def setUp(self): + # Tests own their temporary directories and never touch installed data. + temporary = tempfile.TemporaryDirectory(prefix="lithe-settings-functions-helper-") + self.addCleanup(temporary.cleanup) + self.directory = Path(temporary.name) + self.root_patch = patch.object(fixture, "ROOT", self.directory) + self.root_patch.start() + self.addCleanup(self.root_patch.stop) + self.root = self.directory / ".artifacts/plugin-host-acceptance/settings-functions-fixture" + # Only Git revision lookup is substituted; package bytes/paths are real. + self.revision_patch = patch.object(fixture, "source_revision", return_value="a" * 40) + self.revision_patch.start() + self.addCleanup(self.revision_patch.stop) + self.plan = fixture.generate(self.root) + + def test_package_bytes_are_deterministic_and_sdk_is_not_claimed_executed(self): + other = self.root.with_name("settings-functions-fixture-second") + second = fixture.generate(other) + self.assertEqual(second, self.plan) + self.assertEqual(fixture.load_plan(self.root), self.plan) + self.assertFalse(self.plan["sdk"]["used"]) + for asset in self.plan["archives"].values(): + self.assertEqual((self.root / asset["path"]).read_bytes(), (other / asset["path"]).read_bytes()) + + def test_installed_archive_checks_full_tree_and_preserves_mutation_evidence(self): + archive = self.root / self.plan["archives"]["1.0.0"]["path"] + installed = self.directory / "installed" + with zipfile.ZipFile(archive) as package: + package.extractall(installed) + report = self.directory / "checks/before.json" + acceptance.verify_installed_archive(installed, archive, report) + self.assertFalse(any(json.loads(report.read_text())["diff"].values())) + # A runtime-created empty directory is also a package mutation. + (installed / "runtime-state").mkdir() + failed = report.with_name("after.json") + with self.assertRaisesRegex(AssertionError, "read-only resource mutation"): + acceptance.verify_installed_archive(installed, archive, failed) + self.assertEqual(json.loads(failed.read_text())["diff"]["added"], ["runtime-state/"]) + (installed / "runtime-state").rmdir() + (installed / "server/fake-lsp-server.mjs").write_bytes(b"mutated") + with self.assertRaisesRegex(AssertionError, "read-only resource mutation"): + acceptance.verify_installed_archive(installed, archive, failed) + self.assertEqual(json.loads(failed.read_text())["diff"]["changed"], ["server/fake-lsp-server.mjs"]) + self.assertFalse(any(json.loads(report.read_text())["diff"].values())) + + def test_archive_tampering_is_a_failure(self): + path = self.root / self.plan["archives"]["1.0.0"]["path"] + path.write_bytes(path.read_bytes() + b"changed") + with self.assertRaisesRegex(ValueError, "digest/version"): + fixture.load_plan(self.root) + + def test_controlled_package_contains_the_exact_reviewable_lsp_fixture(self): + self.assertEqual(self.plan["serverSha256"], fixture.sha256(fixture.SERVER)) + for asset in self.plan["archives"].values(): + with zipfile.ZipFile(self.root / asset["path"]) as archive: + manifest = json.loads(archive.read("plugin.json")) + self.assertEqual(archive.read("server/fake-lsp-server.mjs"), fixture.SERVER.read_bytes()) + self.assertEqual(manifest["permissions"], ["languageServer.launch"]) + self.assertEqual(manifest["requires"]["languageServers"], 1) + self.assertEqual(manifest["requires"]["serverProcess"], 1) + self.assertEqual(manifest["languageServers"][0]["launch"], + {"kind": "runtime", "tool": "node", "entry": "server/fake-lsp-server.mjs"}) + + def test_archived_text_settings_accept_the_exercised_default_empty_and_saved_values(self): + for asset in self.plan["archives"].values(): + with zipfile.ZipFile(self.root / asset["path"]) as archive: + declaration = json.loads(archive.read("plugin.json")) + for setting in declaration["settings"]: + # settings:2 requires a whole-value pattern, including for an + # explicit empty override. Check the actual installed inputs. + self.assertEqual(setting["type"], "text") + for value in (setting["default"], "", "global-value", "window-one", "window-two", "project-reload", "global-reload"): + with self.subTest(version=asset["version"], setting=setting["id"], value=value): + self.assertIsNotNone(re.fullmatch(setting["pattern"], value)) + self.assertLessEqual(len(value), setting["maxLength"]) + + def test_plan_from_another_checkout_revision_is_rejected(self): + with patch.object(fixture, "source_revision", return_value="b" * 40): + with self.assertRaisesRegex(ValueError, "identity changed"): + fixture.load_plan(self.root) + + def test_manifest_rewrite_cannot_forge_a_supported_external_package(self): + self.plan["requires"]["buildProviders"] = 1 + (self.root / "settings-functions-plan.json").write_text(json.dumps(self.plan), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "identity changed"): + fixture.load_plan(self.root) + + def test_generator_refuses_overwrite_and_resettable_or_external_roots(self): + with self.assertRaises(FileExistsError): + fixture.generate(self.root) + for output in (self.directory / "external", self.root.parent / "run-image", self.root.parent / "packages"): + with self.subTest(output=output), self.assertRaises(ValueError): + fixture.generate(output) + + def test_controlled_arguments_refuse_missing_identity_and_other_lanes(self): + parser = argparse.ArgumentParser() + args = SimpleNamespace(settings_functions_fixture_root=self.root, settings_functions_host_identity=None, + marketplace=False, lsp_lifecycle=False, build_fixture_root=None) + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + acceptance.validate_arguments(parser, args) + args.settings_functions_host_identity = self.directory / "identity.json" + acceptance.validate_arguments(parser, args) + for other in ("marketplace", "lsp_lifecycle", "build_fixture_root"): + setattr(args, other, True) + with self.subTest(other=other), contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + acceptance.validate_arguments(parser, args) + setattr(args, other, False) + + def test_snapshot_includes_empty_directories_and_detects_all_mutations(self): + resources = self.directory / "resources" + resources.mkdir() + (resources / "empty").mkdir() + (resources / "tool.bin").write_bytes(b"baseline") + before = acceptance.snapshot_resources(resources) + self.assertIn("empty/", before) + (resources / "empty").rmdir() + (resources / "tool.bin").write_bytes(b"mutated") + (resources / "new").write_bytes(b"new") + after = acceptance.snapshot_resources(resources) + self.assertEqual(acceptance.resource_diff(before, after), {"added": ["new"], "removed": ["empty/"], "changed": ["tool.bin"]}) + with self.assertRaisesRegex(AssertionError, "read-only resource mutation"): + acceptance.assert_resources(before, after) + + def test_snapshot_enforces_entry_and_byte_budgets(self): + with self.assertRaisesRegex(ValueError, "entry budget"): + acceptance.snapshot_resources(self.root, max_files=1) + with self.assertRaisesRegex(ValueError, "byte budget"): + acceptance.snapshot_resources(self.root, max_bytes=1) + + def test_snapshot_refuses_junction_and_does_not_hash_external_files(self): + if os.name != "nt": + self.skipTest("Windows junction boundary") + import _winapi + link = self.directory / "junction" + _winapi.CreateJunction(str(self.root), str(link)) + self.addCleanup(lambda: link.rmdir() if link.exists() else None) + with self.assertRaisesRegex(ValueError, "link/reparse"): + acceptance.snapshot_resources(link) + with self.assertRaisesRegex(ValueError, "link or junction"): + fixture.generate(link / "settings-functions-fixture-escaped") + + def test_wait_deadline_uses_injected_clock_without_sleep(self): + clock = [0] + polls = [] + def pump(): + clock[0] += 1 + with self.assertRaisesRegex(TimeoutError, "settings/functions deadline: stalled native reload"): + acceptance.wait_for(lambda: polls.append(clock[0]), "stalled native reload", now=lambda: clock[0], pump=pump, seconds=3) + self.assertEqual(polls, [0, 1, 2]) + + def test_wait_propagates_product_failure_and_never_turns_it_into_pending(self): + def failed(): + raise ValueError("native command failed") + pump = Mock() + with self.assertRaisesRegex(ValueError, "native command failed"): + acceptance.wait_for(failed, "failure", now=lambda: 0, pump=pump) + pump.assert_not_called() + + def test_host_mismatch_rejects_before_product_restart(self): + executable = self.directory / "host.exe" + executable.write_bytes(b"controlled identity bytes, never launched") + path = self.directory / "identity.json" + identity = {"identifier": "app.lithe.windows.host-acceptance", "rustTarget": "x86_64-pc-windows-msvc", + "gitRevision": self.plan["sourceCommit"], "executableSha256": fixture.sha256(executable)} + path.write_text(json.dumps(identity), encoding="utf-8") + self.assertEqual(acceptance.verify_host(path, executable, self.plan), identity) + for key, bad in (("identifier", "production"), ("rustTarget", "aarch64-pc-windows-msvc"), + ("gitRevision", "b" * 40), ("executableSha256", "0" * 64)): + with self.subTest(key=key): + path.write_text(json.dumps({**identity, key: bad}), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "identity does not match"): + acceptance.verify_host(path, executable, self.plan) + + def test_complete_sample_remains_blocked_even_when_controlled_settings_are_supported(self): + rows = acceptance.pending_cases(self.plan, acceptance.source_capabilities()) + self.assertEqual(len(rows), len(acceptance.SETTINGS_CASES) + len(acceptance.FUNCTION_CASES) + 2) + self.assertTrue(all(row["status"] == "skipped" for row in rows)) + for row in rows: + if row["name"].startswith("real-functions"): + self.assertIn("buildProviders:1", row["details"]) + self.assertIn("buildSystems:1", row["details"]) + self.assertIn("publisher-signed", row["details"]) + self.assertIn("No existing dedicated native acceptance seam", row["details"]) + + def test_preflight_pending_is_nonzero_and_never_native_pass(self): + report = self.directory / "pending.json" + with patch.object(sys, "argv", ["preflight", "--fixture-root", str(self.root), "--report", str(report)]), contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(acceptance.main(), 2) + parsed = json.loads(report.read_text(encoding="utf-8")) + self.assertFalse(parsed["complete"]) + self.assertTrue(all(row["status"] == "skipped" for row in parsed["tests"])) + + def test_preflight_input_failure_has_failed_json_and_nonzero_exit(self): + (self.root / "settings-functions-plan.json").write_text("broken", encoding="utf-8") + report = self.directory / "failure.json" + with patch.object(sys, "argv", ["preflight", "--fixture-root", str(self.root), "--report", str(report)]), contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(acceptance.main(), 1) + rows = json.loads(report.read_text(encoding="utf-8"))["tests"] + self.assertEqual(rows[0]["status"], "failed") + self.assertIn("JSONDecodeError", rows[0]["details"]) + + def test_failure_restores_observer_and_preserves_resource_failure_report(self): + # Exercise the real Reporter and lane finally without starting a host. + from acceptance_driver import Reporter + args = SimpleNamespace(settings_functions_fixture_root=self.root, artifact_root=self.directory / "evidence", + run_image=self.directory / "image", app_local_data=self.directory / "app", + settings_functions_host_identity=self.directory / "identity.json", executable=self.directory / "host.exe") + args.run_image.mkdir() + reporter, driver, page = Reporter(), Mock(), Mock() + driver.run_bounded_process.return_value = SimpleNamespace(returncode=0, stdout="v22.0.0") + page.evaluate.return_value = {"calls": [], "dropped": 0, "pending": 0, "originalInvokeRestored": True} + stop = Mock() + lane = acceptance.SettingsFunctionsAcceptance(driver, args, reporter, Mock(return_value=(page, Mock())), stop) + def fail_install(_): + (args.run_image / "illegal-runtime-write").write_bytes(b"mutation") + raise RuntimeError("controlled install failure") + lane.install = fail_install + with patch.object(acceptance, "verify_host", return_value={}), contextlib.redirect_stdout(io.StringIO()): + with self.assertRaisesRegex(AssertionError, "read-only resource mutation"): + lane.run() + self.assertTrue(any(".stop()" in call.args[0] for call in page.evaluate.call_args_list)) + stop.assert_called_once_with() + self.assertEqual([row["status"] for row in reporter.tests], ["failed", "passed", "failed"]) + self.assertIn("controlled install failure", reporter.tests[0]["details"]) + self.assertIn("read-only resource mutation", reporter.tests[2]["details"]) + + def test_failure_cleanup_attempts_both_observers(self): + from acceptance_driver import Reporter + args = SimpleNamespace(settings_functions_fixture_root=self.root, artifact_root=self.directory / "evidence", + run_image=self.directory / "image", app_local_data=self.directory / "app", + settings_functions_host_identity=self.directory / "identity.json", executable=self.directory / "host.exe") + args.run_image.mkdir() + page, second = Mock(), Mock() + page.evaluate.return_value = second.evaluate.return_value = {"calls": [], "dropped": 0, "pending": 0, "originalInvokeRestored": True} + driver = Mock() + driver.run_bounded_process.return_value = SimpleNamespace(returncode=0, stdout="v22.0.0") + stop = Mock() + lane = acceptance.SettingsFunctionsAcceptance(driver, args, Reporter(), Mock(return_value=(page, Mock())), stop) + lane.second = second + lane.install = Mock(side_effect=RuntimeError("install failed")) + with patch.object(acceptance, "verify_host", return_value={}), contextlib.redirect_stdout(io.StringIO()): + with self.assertRaisesRegex(RuntimeError, "install failed"): + lane.run() + for observer in (page, second): + self.assertTrue(any(".stop()" in call.args[0] for call in observer.evaluate.call_args_list)) + stop.assert_called_once_with() + def first_failed(expression): + raise RuntimeError("closed first window") + page.evaluate.side_effect = first_failed + second.reset_mock() + with self.assertRaisesRegex(RuntimeError, "closed first window"): + lane.stop_observers() + self.assertTrue(any(".stop()" in call.args[0] for call in second.evaluate.call_args_list)) + + def test_native_shutdown_failure_is_reported_and_final_resource_hashes_include_shutdown_writes(self): + from acceptance_driver import Reporter + args = SimpleNamespace(settings_functions_fixture_root=self.root, artifact_root=self.directory / "evidence", + run_image=self.directory / "image", app_local_data=self.directory / "app", + settings_functions_host_identity=self.directory / "identity.json", executable=self.directory / "host.exe") + args.run_image.mkdir() + page, driver, reporter = Mock(), Mock(), Reporter() + page.evaluate.return_value = {"calls": [], "dropped": 0, "pending": 0, "originalInvokeRestored": True} + driver.run_bounded_process.return_value = SimpleNamespace(returncode=0, stdout="v22.0.0") + def stop(): + self.assertTrue(any(".stop()" in call.args[0] for call in page.evaluate.call_args_list)) + (args.run_image / "shutdown-write").write_bytes(b"forbidden") + raise RuntimeError("native graceful quit failed") + lane = acceptance.SettingsFunctionsAcceptance(driver, args, reporter, Mock(return_value=(page, Mock())), stop) + for name in ("install", "public_search_clear", "explicit_save", "two_windows", "reload", "lifecycle"): + setattr(lane, name, Mock()) + with patch.object(acceptance, "verify_host", return_value={}), contextlib.redirect_stdout(io.StringIO()): + with self.assertRaisesRegex(AssertionError, "read-only resource mutation"): + lane.run() + self.assertEqual([row["status"] for row in reporter.tests], ["passed"] * 7 + ["failed", "failed"]) + self.assertIn("native graceful quit failed", reporter.tests[-2]["details"]) + self.assertIn("shutdown-write", reporter.tests[-1]["details"]) + + def test_failed_shutdown_cannot_pass_resource_invariance_when_hashes_match(self): + from acceptance_driver import Reporter + args = SimpleNamespace(settings_functions_fixture_root=self.root, artifact_root=self.directory / "evidence", + run_image=self.directory / "image", app_local_data=self.directory / "app", + settings_functions_host_identity=self.directory / "identity.json", executable=self.directory / "host.exe") + args.run_image.mkdir() + page, driver, reporter = Mock(), Mock(), Reporter() + page.evaluate.return_value = {"calls": [], "dropped": 0, "pending": 0, "originalInvokeRestored": True} + driver.run_bounded_process.return_value = SimpleNamespace(returncode=0, stdout="v22.0.0") + stop = Mock(side_effect=RuntimeError("native graceful quit failed")) + lane = acceptance.SettingsFunctionsAcceptance(driver, args, reporter, Mock(return_value=(page, Mock())), stop) + for name in ("install", "public_search_clear", "explicit_save", "two_windows", "reload", "lifecycle"): + setattr(lane, name, Mock()) + with patch.object(acceptance, "verify_host", return_value={}), contextlib.redirect_stdout(io.StringIO()): + with self.assertRaisesRegex(AssertionError, "native shutdown did not complete"): + lane.run() + self.assertEqual([row["status"] for row in reporter.tests[-2:]], ["failed", "failed"]) + self.assertIn("native graceful quit failed", reporter.tests[-2]["details"]) + + def test_observer_pending_deadline_is_failed_and_does_not_skip_the_other_window(self): + args = SimpleNamespace(settings_functions_fixture_root=self.root, artifact_root=self.directory / "evidence") + driver, page, second = Mock(), Mock(), Mock() + lane = acceptance.SettingsFunctionsAcceptance(driver, args, Mock(), Mock(), Mock()) + lane.page, lane.second = page, second + page.evaluate.return_value = {"calls": [{"settled": False}], "dropped": 0, "pending": 1, "originalInvokeRestored": True} + second.evaluate.return_value = {"calls": [], "dropped": 0, "pending": 0, "originalInvokeRestored": True} + with self.assertRaisesRegex(RuntimeError, "unsettled native calls"): + lane.stop_observers() + self.assertTrue(any(".stop()" in call.args[0] for call in second.evaluate.call_args_list)) + self.assertTrue(any(call.args[1].get("pending") == 1 for call in driver.write_json.call_args_list)) + + def test_real_javascript_observer_drains_late_replies_and_bounds_unsettled_calls(self): + node = shutil.which("node") + self.assertIsNotNone(node, "Node is required by the acceptance helper CI lane") + script = r""" +const assert=require('node:assert/strict'), vm=require('node:vm'); +const source=JSON.parse(require('node:fs').readFileSync(0,'utf8')); +function setup() { + const timers=new Map(), callbacks=new Map(), requests=[]; let sequence=0; + const nativeFetch=function(...args){requests.push(args);return Promise.resolve('native response');}; + const window={fetch:nativeFetch,location:{href:'http://tauri.localhost/'}}; + const internals={}; + Object.defineProperty(window,'__TAURI_INTERNALS__',{value:internals}); + Object.defineProperty(internals,'callbacks',{value:callbacks}); + Object.defineProperty(internals,'invoke',{value:function(command,args={}) { + return new Promise((resolve,reject)=>{ + const ok=++sequence, error=++sequence; + callbacks.set(ok,value=>{callbacks.delete(ok);callbacks.delete(error);resolve(value);}); + callbacks.set(error,value=>{callbacks.delete(ok);callbacks.delete(error);reject(value);}); + window.fetch('http://ipc.localhost/'+command,{body:JSON.stringify(args), + headers:new Headers({'Tauri-Callback':String(ok),'Tauri-Error':String(error)})}); + }); + }}); + vm.runInNewContext('('+source+')()', {window,URL, + setTimeout:(callback,ms)=>{assert.equal(ms,10000);timers.set(++sequence,callback);return sequence;}, + clearTimeout:id=>timers.delete(id)}); + const reply=(index,failed,value)=>{ + const options=requests[index][1]; + callbacks.get(Number(options.headers.get(failed?'Tauri-Error':'Tauri-Callback')))(value); + }; + return {window,timers,callbacks,requests,reply,nativeFetch,state:window.__litheSettingsAcceptance}; +} +(async()=>{ + const passthrough=setup(); + for(const input of ['/relative-resource',new URL('http://tauri.localhost/resource'),{url:'http://tauri.localhost/request'},'http://[invalid']) { + assert.equal(await passthrough.window.fetch(input),'native response'); + assert.equal(passthrough.requests.at(-1)[0],input); + } + assert.equal(passthrough.state.calls.length,0);await passthrough.state.stop(); + const normal=setup(), internals=normal.window.__TAURI_INTERNALS__, original=internals.invoke; + assert.equal(Object.getOwnPropertyDescriptor(internals,'invoke').writable,false); + const unobserved=internals.invoke('unobserved',{value:42}); normal.reply(0,false,42); + assert.equal(await unobserved,42); + const succeeded=internals.invoke('declarative_plugins_success',{value:'draft'}).then(value=>assert.equal(value,'late result')); + const failed=internals.invoke('declarative_plugins_failure').then(()=>assert.fail('failure became success'),error=>assert.equal(error,'late error')); + let finished=false; + const stopping=normal.state.stop().then(result=>{finished=true;return result;}); + assert.equal(internals.invoke,original);assert.equal(normal.window.fetch,normal.nativeFetch); + await Promise.resolve(); assert.equal(finished,false); + normal.reply(1,false,'late result'); normal.reply(2,true,'late error'); + await Promise.all([succeeded,failed]); + const final=await stopping; + assert.equal(final.originalInvokeRestored,true); assert.equal(final.pending,0); + assert.equal(final.calls.length,2); assert.ok(final.calls.every(call=>call.settled)); + assert.equal(final.calls[0].args.value,'draft'); + assert.equal(final.calls[0].value,'late result'); assert.equal(final.calls[1].error,'late error'); + assert.equal(normal.callbacks.size,0);assert.equal(normal.timers.size,0); + const stalled=setup(); + const outstanding=stalled.window.__TAURI_INTERNALS__.invoke('declarative_plugins_stalled'); + try { + const stop=stalled.state.stop(); + assert.equal(stalled.timers.size,1); + [...stalled.timers.values()][0](); // Advance injected deadline; never sleep. + const timed=await stop; + assert.equal(timed.originalInvokeRestored,true); assert.equal(timed.pending,1); + assert.equal(timed.calls[0].settled,false); assert.equal(stalled.timers.size,0); + } finally {stalled.reply(0,false,null);await outstanding;} + const changed=setup(), replacement=()=>{}; + changed.window.fetch=replacement; + await assert.rejects(changed.state.stop(),/native invoke changed/); + assert.equal(changed.window.fetch,replacement); +})().catch(error=>{console.error(error);process.exitCode=1;}); +""" + result = subprocess.run([node, "-e", script], input=json.dumps(acceptance.OBSERVER), + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_pending_registration_keeps_existing_failures(self): + name = "controlled-settings / trusted-install-review" + reporter = SimpleNamespace(tests=[{"name": name, "status": "failed"}]) + acceptance.record_unreached_cases(reporter, self.plan) + self.assertEqual(sum(row["name"] == name for row in reporter.tests), 1) + self.assertEqual(reporter.tests[0]["status"], "failed") + + def test_search_baseline_failure_saves_partial_evidence_and_disposes_input(self): + # Helper evidence covers failure/cleanup only, never keyboard acceptance. + args = SimpleNamespace(settings_functions_fixture_root=self.root, artifact_root=self.directory / "evidence") + driver, page = Mock(), Mock() + lane = acceptance.SettingsFunctionsAcceptance(driver, args, Mock(), Mock(), Mock()) + lane.page = page + lane.details = Mock() + handle = page.locator.return_value.get_by_role.return_value.element_handle.return_value + handle.evaluate.return_value = {"value": "persisted stale filter"} + with self.assertRaisesRegex(AssertionError, "search baseline"): + lane.public_search_clear() + handle.dispose.assert_called_once_with() + page.keyboard.insert_text.assert_not_called() + page.keyboard.down.assert_not_called() + page.keyboard.up.assert_not_called() + path, evidence = driver.write_json.call_args.args + self.assertEqual(path.name, "public-search-clear-default-activation.json") + self.assertNotIn("passed", evidence) + self.assertEqual(evidence["snapshots"]["before"]["value"], "persisted stale filter") + + def test_search_failure_is_registered_and_native_finally_cleanup_still_runs(self): + from acceptance_driver import Reporter + args = SimpleNamespace(settings_functions_fixture_root=self.root, artifact_root=self.directory / "evidence", + run_image=self.directory / "image", app_local_data=self.directory / "app", + settings_functions_host_identity=self.directory / "identity.json", executable=self.directory / "host.exe") + args.run_image.mkdir() + page, driver, reporter, stop = Mock(), Mock(), Reporter(), Mock() + driver.run_bounded_process.return_value = SimpleNamespace(returncode=0, stdout="v22.0.0") + page.evaluate.return_value = {"calls": [], "dropped": 0, "pending": 0, "originalInvokeRestored": True} + lane = acceptance.SettingsFunctionsAcceptance(driver, args, reporter, Mock(return_value=(page, Mock())), stop) + lane.install = Mock() + lane.public_search_clear = Mock(side_effect=AssertionError("browser default activation failed")) + lane.explicit_save = Mock() + with patch.object(acceptance, "verify_host", return_value={}), contextlib.redirect_stdout(io.StringIO()): + with self.assertRaisesRegex(AssertionError, "browser default activation failed"): + lane.run() + acceptance.record_unreached_cases(reporter, self.plan) + rows = [row for row in reporter.tests if row["name"] == "controlled-settings / public-search-clear-default-activation"] + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["status"], "failed") + lane.explicit_save.assert_not_called() + stop.assert_called_once_with() + self.assertTrue(any(".stop()" in call.args[0] for call in page.evaluate.call_args_list)) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--report", type=Path) + parser.add_argument("--case") + args = parser.parse_args() + if args.case: + result = unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromName(args.case, sys.modules[__name__])) + return 0 if result.wasSuccessful() else 1 + if args.report is None: + parser.error("--report is required") + rows = [] + for name in unittest.defaultTestLoader.getTestCaseNames(SettingsFunctionsHelperTests): + started = time.monotonic() + status, details = "passed", "" + try: + result = subprocess.run([sys.executable, "-B", str(Path(__file__).resolve()), "--case", f"SettingsFunctionsHelperTests.{name}"], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10) + if result.returncode: + status, details = "failed", result.stdout + result.stderr + except subprocess.TimeoutExpired: + status, details = "timeout", "driver self-check exceeded its 10 second process deadline" + duration = round((time.monotonic() - started) * 1000, 3) + if duration >= 10_000: + status, details = "timeout", details + "; hard per-case timing budget exceeded" + rows.append({"name": name, "suite": "plugin-host-acceptance / settings-functions-helpers", "status": status, + "durationMs": duration, "maxMs": 10_000, "details": details}) + print(f"{status.upper()} {name} ({duration} ms)", flush=True) + if details: + print(details, file=sys.stderr) + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps({"runner": "acceptance", "warnMs": 2_000, "maxMs": 10_000, "tests": rows}, indent=2) + "\n", encoding="utf-8") + return 1 if any(row["status"] != "passed" for row in rows) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/settings_functions_fixture.py b/scripts/plugin-host-acceptance/settings_functions_fixture.py new file mode 100644 index 0000000000..df9746fb1b --- /dev/null +++ b/scripts/plugin-host-acceptance/settings_functions_fixture.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Disposable settings-only inputs; never rewrite a published package's requires. + +The Node SDK sample is an audited, blocked external prerequisite, not the source +of this deliberately unsigned controlled fixture. No download or SDK execution +occurs here. Use the normal native install review to validate generated ZIPs. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import subprocess +import zipfile +from pathlib import Path + +from build_fixture import ROOT, confined, json_bytes, ordinary_tree, write_archive + +PLUGIN_ID = "io.github.lithe-acceptance.settings" +DISPLAY_NAME = "Lithe Settings Acceptance" +LOCK = Path(__file__).with_name("settings_functions_inputs.json") +SERVER = Path(__file__).with_name("fake-lsp-server.mjs") + + +def sha256(path: Path) -> str: + with path.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def declaration(version: str) -> dict: + # A new controlled package, using the existing acceptance LSP transport. + # It is not a reduced copy of settings-v2 or an external Provider package. + return { + "schemaVersion": 2, "id": PLUGIN_ID, "displayName": DISPLAY_NAME, + "version": version, "pluginAPIVersion": 3, + "requires": {"languages": 1, "settings": 2, "languageServers": 1, "serverProcess": 1}, + "vendor": {"id": "io.github.lithe-acceptance", "displayName": "Unsigned controlled fixture", + "signatureRequirement": "none"}, + "permissions": ["languageServer.launch"], "activationEvents": ["onLanguage:lithesettingsaccept"], + "languages": [{"id": "lithesettingsaccept", "displayName": DISPLAY_NAME, + "fileExtensions": ["lithesettingsaccept"]}], + "languageServers": [{"languageID": "lithesettingsaccept", "languageIdentifier": "lithesettingsaccept", + "launch": {"kind": "runtime", "tool": "node", "entry": "server/fake-lsp-server.mjs"}, + "arguments": ["--stdio", "--state-dir", "${workspaceStateDir}", "--fixture-version", version]}], + "settings": [ + {"id": "message", "title": "Acceptance message", "type": "text", "default": "default", + "scope": "project", "effect": "nextRun", "pattern": ".*", "maxLength": 64}, + {"id": "reload", "title": "Acceptance reload", "type": "text", "default": "default", + "scope": "project", "effect": "reload", "pattern": ".*", "maxLength": 64, + "page": {"languageID": "lithesettingsaccept"}, "section": "Acceptance"}, + ], + } + + +def check_output_path(output: Path) -> None: + # Must use the existing run-owned exclusion; no new transferable cache. + parent = ROOT / ".artifacts/plugin-host-acceptance" + if parent.resolve() not in output.resolve().parents: + raise ValueError("fixture output must be below .artifacts/plugin-host-acceptance") + if not output.resolve().relative_to(parent.resolve()).parts[0].startswith("settings-functions-fixture"): + raise ValueError("use a dedicated settings-functions-fixture directory, outside resettable run state") + for path in (output, *output.parents): + if path.is_symlink() or path.is_junction(): + raise ValueError("fixture output cannot traverse a link or junction") + + +def source_revision() -> str: + return subprocess.run(["git", "rev-parse", "HEAD"], cwd=ROOT, capture_output=True, + text=True, check=True, timeout=10).stdout.strip() + + +def generate(output: Path) -> dict: + check_output_path(output) + output.mkdir(parents=True, exist_ok=False) + archives = {} + for version in ("1.0.0", "1.1.0"): + path = output / f"settings-{version}.zip" + write_archive(path, {"plugin.json": json_bytes(declaration(version)), + "fixture-version.txt": (version + "\n").encode(), + "server/fake-lsp-server.mjs": SERVER.read_bytes()}) + archives[version] = {"path": path.name, "sha256": sha256(path), "version": version} + for name in ("one", "two"): + workspace = output / name + workspace.mkdir() + (workspace / "acceptance.lithesettingsaccept").write_text("controlled settings input\n", encoding="utf-8") + revision = source_revision() + plan = {"schemaVersion": 1, "kind": "controlled-settings-only", "pluginId": PLUGIN_ID, + "sourceCommit": revision, "generatorSha256": sha256(Path(__file__)), + "inputLockSha256": sha256(LOCK), "serverSha256": sha256(SERVER), + "sdk": {"used": False, "reference": json.loads(LOCK.read_text(encoding="utf-8"))}, + "requires": declaration("1.0.0")["requires"], "archives": archives, + "workspaces": ["one", "two"]} + (output / "settings-functions-plan.json").write_bytes(json_bytes(plan)) + return plan + + +def load_plan(root: Path) -> dict: + # Read every generated input through the existing ordinary-file guard. + check_output_path(root) + ordinary_tree(root) + plan = json.loads((root / "settings-functions-plan.json").read_text(encoding="utf-8")) + if (plan.get("schemaVersion") != 1 or plan.get("kind") != "controlled-settings-only" + or plan.get("pluginId") != PLUGIN_ID or plan.get("requires") != declaration("1.0.0")["requires"] + or plan.get("sourceCommit") != source_revision() + or plan.get("generatorSha256") != sha256(Path(__file__)) or plan.get("inputLockSha256") != sha256(LOCK) + or plan.get("serverSha256") != sha256(SERVER) + or plan.get("sdk") != {"used": False, "reference": json.loads(LOCK.read_text(encoding="utf-8"))} + or plan.get("workspaces") != ["one", "two"] or set(plan.get("archives", {})) != {"1.0.0", "1.1.0"}): + raise ValueError("fixture identity changed; regenerate in this worktree") + for version, asset in plan["archives"].items(): + path = confined(root, asset["path"]) + if asset.get("version") != version or sha256(path) != asset["sha256"]: + raise ValueError("fixture archive digest/version changed") + with zipfile.ZipFile(path) as archive: + if sorted(archive.namelist()) != ["fixture-version.txt", "plugin.json", "server/fake-lsp-server.mjs"]: + raise ValueError("unexpected settings fixture package content") + if archive.read("server/fake-lsp-server.mjs") != SERVER.read_bytes(): + raise ValueError("settings fixture server identity changed") + if json.loads(archive.read("plugin.json")) != declaration(version): + raise ValueError("complete settings manifest changed") + for name in plan["workspaces"]: + confined(root, name).resolve(strict=True) + return plan + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + plan = generate(args.output) + print(json.dumps(plan, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/settings_functions_inputs.json b/scripts/plugin-host-acceptance/settings_functions_inputs.json new file mode 100644 index 0000000000..dc6e9d996f --- /dev/null +++ b/scripts/plugin-host-acceptance/settings_functions_inputs.json @@ -0,0 +1,30 @@ +{ + "repository": "Lithe-IDEA/lithe-plugin-docs", + "sourceCommit": "aa9c4433cf3af268c506b1279391488f47703f3e", + "sdkCommit": "aa9c4433cf3af268c506b1279391488f47703f3e", + "sdkVersion": "0.1.0", + "sample": "sdk/node/examples/sample-build", + "pluginId": "org.example.sample-build", + "pluginVersion": "0.1.0", + "license": "Apache-2.0", + "nodeMinimum": "20.3", + "requires": { "buildProviders": 1, "buildSystems": 1, "serverProcess": 1 }, + "signatureRequirement": "publisherPackage", + "sha256": { + "sdk/node/examples/sample-build/plugin.json": "b15ad11758aa326fe9ed0e281dd7e80d02f6f2f1d83d41159e179aacc32f3c73", + "sdk/node/examples/sample-build/backend/main.mjs": "805950e1e99a82cd30fbc2389c8a969435ba800ad44a7cbbaf5f4b5f2630fc80", + "sdk/node/package.json": "59748a9d3c2684c91090015db1a26de0dffc32c0bb8ebb93b929a59d9af1d617", + "LICENSE": "93ff573f89d32394501fcc82767e73b1824add861f088bbd3eec9e0afae20d21" + }, + "audit": { + "method": "gh api contents at the exact commit, raw response bytes hashed with SHA-256", + "functions": ["workspace.build", "snapshot.read", "snapshot.clear"], + "blockers": [ + "The complete package requires buildProviders:1 and buildSystems:1, currently closed in Windows.", + "Source is available; no verified publisher-signed complete Windows acceptance archive or archive digest is supplied.", + "The source sample imports the SDK from its repository-relative path; upstream packaging must vendor the SDK with a valid import before signing.", + "The sample declares no settingActions or web UI. No unmodified package currently covers both product entry points.", + "No existing dedicated native acceptance seam can deterministically force a process cleanup failure. Retry and late-callback checks remain pending." + ] + } +} diff --git a/scripts/plugin-host-acceptance/text_window_acceptance.py b/scripts/plugin-host-acceptance/text_window_acceptance.py new file mode 100644 index 0000000000..ce54996023 --- /dev/null +++ b/scripts/plugin-host-acceptance/text_window_acceptance.py @@ -0,0 +1,492 @@ +#!/usr/bin/env python3 +"""Real Windows x64 ui:1 literal rendering; passive request observation only.""" +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import http.client +import importlib.metadata +import json +import os +import platform +import re +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +from build_fixture import confined, json_bytes +from settings_functions_acceptance import assert_resources, resource_diff, snapshot_resources, verify_host, wait_for +from text_window_fixture import DISPLAY_NAME, PLUGIN_ID, WINDOW_ID, declaration, generate, literal_text, load_plan, sha256 +from web_bridge_fallback_acceptance import SNAPSHOT, assert_transport, cdp_send, require_x64_executable + +ORIGIN = "http://lithe-plugin." + PLUGIN_ID +TEXT_URL = ORIGIN + "/__lithe_host__/text/" + WINDOW_ID.encode().hex() + ".html" +BRIDGE_URL = ORIGIN + "/__lithe_host__/bridge.js" +CASES = ("identity-and-install-review", "preload-passive-observer", "private-host-text-page", + "literal-dom-and-transport", "hide-and-reopen", "literal-network-and-ui-observation", + "close-and-uninstall", "product-profile-counter-cleanup", "resource-invariance") + + +def read(page, expression): + # Page.evaluate has no public timeout. Bound the pinned observer bridge, + # including synchronous DOM/descriptor reads if a renderer stops replying. + return page._sync(asyncio.wait_for(page._impl_obj.evaluate(expression), timeout=10)) + + +def snapshot(page): + result = read(page, SNAPSHOT) + for descriptor in result["descriptors"].values(): + if descriptor and descriptor.get("source") is not None: + descriptor["sourceSha256"] = hashlib.sha256(descriptor["source"].encode()).hexdigest() + return result + + +class Counter: + """One loopback server, bound before fixture generation, with bounded teardown.""" + def __init__(self): + self.records = [] + self.controls = [] + records = self.records + controls = self.controls + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + (controls if self.path == "/counter-control" else records).append({"method": self.command, "path": self.path}) + self.send_response(204) + self.end_headers() + do_POST = do_HEAD = do_OPTIONS = do_GET + def log_message(self, *_args): + pass + self.server = HTTPServer(("127.0.0.1", 0), Handler) + # Accepted sockets cannot hold shutdown indefinitely. + self.server.timeout = 0.2 + original = self.server.get_request + def get_request(): + sock, address = original() + sock.settimeout(1) + return sock, address + self.server.get_request = get_request + self.stop = threading.Event() + self.thread = threading.Thread(target=self.serve, name="literal-counter", daemon=True) + self.endpoint = f"http://127.0.0.1:{self.server.server_port}/literal-probe" + self.closed = False + + def serve(self): + while not self.stop.is_set(): + self.server.handle_request() + + def start(self): + self.thread.start() + connection = http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=2) + try: + connection.request("GET", "/counter-control") + response = connection.getresponse() + response.read() + if response.status != 204 or self.controls != [{"method": "GET", "path": "/counter-control"}]: + raise AssertionError("counter positive control failed") + finally: + connection.close() + + def close(self): + self.stop.set() + if self.thread.ident is not None: + self.thread.join(timeout=3) + self.server.server_close() + self.closed = not self.thread.is_alive() + if not self.closed: + raise RuntimeError("literal counter survived its cleanup deadline") + + +class PassiveRequests: + """Subscribe before opening a target; never route, intercept, or replace APIs. + + Pinned Playwright initializes Network before resuming a newly attached page. + Requiring the original Document AND host bridge records detects a late or + wrong-context observer instead of passing an empty trace. + """ + def __init__(self, browser): + self.contexts = list(browser.contexts) + self.requests, self.responses, self.events, self.handlers = [], [], [], [] + for context in self.contexts: + context.on("request", self.on_request) + context.on("response", self.on_response) + context.on("dialog", self.on_dialog) + context.on("page", self.on_page) + + def on_request(self, request): + try: + owner = request.frame.page + except Exception: + owner = None + self.requests.append((owner, {"url": request.url, "method": request.method, + "resourceType": request.resource_type})) + + def on_response(self, response): + try: + owner = response.request.frame.page + except Exception: + owner = None + self.responses.append((owner, response)) + + def response_evidence(self, page): + rows = [] + for owner, response in self.responses: + if owner is page: + body = response._sync(asyncio.wait_for(response._impl_obj.body(), timeout=10)) + headers = response.headers + rows.append({"url": response.url, "status": response.status, "bytes": len(body), + "sha256": hashlib.sha256(body).hexdigest(), + "csp": headers.get("content-security-policy"), "contentType": headers.get("content-type")}) + if ({row["url"] for row in rows} != {TEXT_URL, BRIDGE_URL} + or any(row["status"] != 200 or not row["csp"] for row in rows)): + raise AssertionError("original host responses/CSP were not captured") + return rows + + def on_dialog(self, dialog): + self.events.append({"kind": "dialog", "type": dialog.type, "message": dialog.message}) + dialog.dismiss() + + def on_page(self, page): + def download(item): + self.events.append({"kind": "download", "url": item.url}) + item.cancel() + def popup(item): + self.events.append({"kind": "popup", "url": item.url}) + page.on("download", download) + page.on("popup", popup) + self.handlers.append((page, download, popup)) + + def rows(self, page): + if page.context not in self.contexts: + raise AssertionError("private page was created outside pre-observed contexts") + return [row for owner, row in self.requests if owner is page] + + def assert_page(self, page): + rows = self.rows(page) + expected = {(TEXT_URL, "GET", "document"), (BRIDGE_URL, "GET", "script")} + actual = [(r["url"], r["method"], r["resourceType"]) for r in rows] + if len(actual) != 2 or set(actual) != expected or self.events: + raise AssertionError(f"unexpected/missing original request or UI side effect: {actual}; {self.events}") + return rows + + def close(self): + for context in self.contexts: + context.remove_listener("request", self.on_request) + context.remove_listener("response", self.on_response) + context.remove_listener("dialog", self.on_dialog) + context.remove_listener("page", self.on_page) + for page, download, popup in self.handlers: + page.remove_listener("download", download) + page.remove_listener("popup", popup) + + +DOM = r"""() => ({url:location.href, text:document.querySelector('pre')?.textContent, + bodyTags:[...document.body.querySelectorAll('*')].map(x=>x.tagName), + scripts:[...document.scripts].map(x=>({src:x.src,text:x.textContent})), + dangerous:document.querySelectorAll('iframe,img,object,embed,form,a,link').length, + injected:Object.prototype.hasOwnProperty.call(window,'__literalExecuted'), + context:window.lithe.context, ready:document.readyState})""" + + +def assert_dom(result, endpoint): + if (result["url"] != TEXT_URL or result["text"] != literal_text(endpoint) + or result["bodyTags"] != ["PRE"] or result["dangerous"] or result["injected"] + or result["scripts"] != [{"src": BRIDGE_URL, "text": ""}] + or result["context"]["pluginID"] != PLUGIN_ID or result["context"]["windowID"] != WINDOW_ID): + raise AssertionError("literal text, host shell/script or scoped identity mismatch") + + +def run(args, driver, reporter): + evidence = args.artifact_root + evidence.mkdir(parents=True, exist_ok=False) + baseline = snapshot_resources(args.run_image) + session = page = plugin = observer = counter = None + first_failure = None + package_before = plan = None + archive_before = None + shutdown_completed = False + package_root = args.app_local_data / "plugins" / PLUGIN_ID / "1.0.0" + def write(name, value): + driver.write_json(evidence / (name + ".json"), value) + def step(name): + return reporter.step("controlled-ui1-literal / " + name, max_ms=420_000 if name == CASES[0] else 120_000) + def wait(predicate, description): + return wait_for(predicate, description, pump=lambda: read(page, "() => true"), seconds=15) + def catalog(): + return next((row for row in driver.invoke(page, "declarative_plugins_state") + if row["pluginId"] == PLUGIN_ID and row["source"] == "installed"), None) + def details(): + driver.open_plugin_settings(page) + page.locator(f"[data-plugin-id='{PLUGIN_ID}'][data-plugin-source='installed']").get_by_role("button").click(timeout=10_000) + region = page.locator("[data-settings-content='declarative-plugins']").get_by_role("region", name=DISPLAY_NAME, exact=True) + region.wait_for(state="visible", timeout=15_000) + return region + try: + with driver.sync_playwright() as playwright: + try: + with step(CASES[0]): + counter = Counter() + counter.start() + generate(args.fixture_root, counter.endpoint) + plan = load_plan(args.fixture_root) + archive_before = plan["archive"]["sha256"] + identity = verify_host(args.host_identity, args.executable, plan) + config = args.host_identity.with_name("tauri.host-acceptance.conf.json") + if (identity.get("configSha256") != sha256(config) or identity.get("sourceInventory") != plan["sourceInventory"] + or platform.machine().lower() not in ("amd64", "x86_64") or identity["gitRevision"] != plan["sourceCommit"]): + raise ValueError("full run-source/config/x64 host identity mismatch") + write("identity", {"host": identity, "fixture": plan, "peMachine": require_x64_executable(args.executable), + "acceptanceSources": {name: sha256(Path(__file__).with_name(name)) for name in + ("text_window_fixture.py", "text_window_acceptance.py", "text_window_acceptance_test.py")}}) + write("resources-before", baseline) + session = driver.AppSession(playwright, args.executable, args.fixture_root / "workspace", args.webview_data, evidence / "app", 1) + reporter.capture_failure = lambda: driver.capture_session_failure(session, evidence) + page = session.start() + page.set_default_timeout(10_000) + driver.finish_onboarding(page) + settings = page.get_by_role("button", name=re.compile(r"^(Settings|设置)$")).first + settings.wait_for(state="visible", timeout=15_000) + if settings.get_attribute("aria-label") == "设置": + settings.click(timeout=15_000) + page.locator("[data-settings-category='general']").click(timeout=15_000) + general = page.locator("[data-settings-content='general']") + general.locator("[data-setting-primary-control]").filter(has_text="简体中文").click(timeout=15_000) + page.get_by_role("option", name="English", exact=True).click(timeout=15_000) + general.locator("[data-setting-primary-control]").filter(has_text="English").wait_for(state="visible", timeout=15_000) + page.get_by_role("button", name="Close", exact=True).last.click(timeout=15_000) + general.wait_for(state="hidden", timeout=15_000) + driver.finish_onboarding(page) + review = driver.import_review(page, session.process.pid, confined(args.fixture_root, plan["archive"]["path"])) + review_text = review.inner_text(timeout=5000) + permissions = review.locator("li").all_text_contents() + write("install-review", {"text": review_text, "permissions": permissions, "programCount": 0, "archive": plan["archive"]}) + if ("Unverified publisher" not in review_text or permissions != ["ui.contribute"] + or review.get_by_role("button", name=re.compile(r"^Program declaration \(")).count()): + raise AssertionError("unsigned ui:1-only trust/permission/program review mismatch") + driver.confirm_review(page, review) + wait(lambda: (row if (row := catalog()) and row["status"] == "accepted" and row["enabled"] else None), "accepted literal package") + record = json.loads((package_root.parent / "installation.json").read_text(encoding="utf-8")) + write("installation", record) + if record["disk"]["archiveSha256"] != archive_before or record["disk"]["approvedPrograms"] != []: + raise AssertionError("installation approval/archive identity mismatch") + package_before = snapshot_resources(package_root) + data = json_bytes(declaration(counter.endpoint)) + assert_resources({"plugin.json": {"kind": "file", "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}}, package_before) + write("package-before", package_before) + with step(CASES[1]): + import playwright as playwright_package + if importlib.metadata.version("playwright") != "1.60.0": + raise ValueError("passive observer requires pinned Playwright 1.60.0") + observer_source = Path(playwright_package.__file__).parent / "driver/package/lib/coreBundle.js" + observer = PassiveRequests(session.browser) + write("preload-observer", {"contexts": len(observer.contexts), "beforeOpen": True, + "mechanism": "Playwright BrowserContext request subscription; no route/Fetch/transport replacement", + "playwrightVersion": importlib.metadata.version("playwright"), + "observerSourceSha256": sha256(observer_source), "browserVersion": session.browser.version, + "coverageGuard": "original Document and bridge requests must both be captured on each page"}) + window_row = page.get_by_test_id("declarative-web-window-list").locator("li").filter(has_text=DISPLAY_NAME) + def open_page(): + window_row.get_by_role("button", name="Open", exact=True).click(timeout=10_000) + def find(): + matches = [p for c in session.browser.contexts for p in c.pages if p.url == TEXT_URL] + if len(matches) > 1: + raise AssertionError("ambiguous private text page") + return matches[0] if matches else None + opened = wait(find, "host-owned private text page") + opened.set_default_timeout(10_000) + opened.wait_for_load_state("load", timeout=15_000) + opened.wait_for_function("() => Boolean(window.lithe?.request)", timeout=15_000) + return opened + with step(CASES[2]): + plugin = open_page() + cdp = plugin.context.new_cdp_session(plugin) + try: + target = cdp_send(cdp, "Target.getTargetInfo")["targetInfo"] + if target["url"] != TEXT_URL or target["type"] != "page": + raise AssertionError("wrong real private CDP target") + write("private-target", target) + finally: + cdp._sync(asyncio.wait_for(cdp._impl_obj.detach(), timeout=10)) + with step(CASES[3]): + dom = read(plugin, DOM) + assert_dom(dom, counter.endpoint) + before = snapshot(plugin) + assert_transport(before, before) + write("dom-first", dom) + write("transport-first", before) + observer.assert_page(plugin) + write("host-responses-first", observer.response_evidence(plugin)) + plugin.screenshot(path=str(evidence / "literal-first.png"), timeout=15_000) + with step(CASES[4]): + # The production Close control hides content by destroying + # its WebView; Open creates a fresh host-owned page. + first_final = snapshot(plugin) + assert_transport(before, first_final) + write("transport-first-final", first_final) + with plugin.expect_event("close", timeout=15_000): + window_row.get_by_role("button", name="Close", exact=True).click(timeout=10_000) + first_page = plugin + plugin = open_page() + if plugin is first_page: + raise AssertionError("hide did not recreate the native page") + dom = read(plugin, DOM) + assert_dom(dom, counter.endpoint) + reopened = snapshot(plugin) + assert_transport(before, reopened) + write("dom-reopened", dom) + write("transport-reopened", reopened) + observer.assert_page(plugin) + write("host-responses-reopened", observer.response_evidence(plugin)) + assert_resources(package_before, snapshot_resources(package_root)) + with step(CASES[5]): + # No native roundtrip is sent from the plugin. This passive + # browser trace observes network requests, not postMessage + # IPC; drained callback snapshots cannot count completed IPC. + read(page, "() => true") + rows = {"first": observer.assert_page(first_page), "reopened": observer.assert_page(plugin)} + if counter.records: + raise AssertionError("literal loopback URL received an HTTP request") + final = snapshot(plugin) + assert_transport(reopened, final) + write("transport-final", final) + write("network-and-ui-events", {"requests": rows, "events": observer.events, + "counterEndpoint": counter.endpoint, "counterRequests": counter.records, + "counterPositiveControl": counter.controls, + "observedPluginNetworkRequests": sum(len(value) for value in rows.values()), + "observedUnexpectedPluginNetworkRequests": sum( + row["url"] not in (TEXT_URL, BRIDGE_URL) for value in rows.values() for row in value), + "transportDescriptorSnapshots": ["transport-first", "transport-first-final", "transport-reopened", "transport-final"], + "ipcObservation": {"postMessageCount": None, + "limitation": "BrowserContext network events cannot observe postMessage IPC; callback snapshots prove only their sampled state."}}) + with step(CASES[6]): + with plugin.expect_event("close", timeout=15_000): + window_row.get_by_role("button", name="Close", exact=True).click(timeout=10_000) + write("package-after", snapshot_resources(package_root)) + assert_resources(package_before, snapshot_resources(package_root)) + details().get_by_role("button", name="Uninstall", exact=True).click(timeout=10_000) + wait(lambda: catalog() is None, "uninstall literal package") + if package_root.parent.exists() or (args.app_local_data / "plugin-state" / PLUGIN_ID).exists(): + raise AssertionError("uninstall retained package/state") + write("uninstall", {"pageClosed": plugin.is_closed(), "packageAndStateAbsent": True}) + except Exception as error: + first_failure = error + write("first-failure", {"type": type(error).__name__, "message": str(error)}) + finally: + cleanup_errors = [] + try: + with step(CASES[7]): + try: + if observer is not None: + try: + if first_failure is None: + observer.assert_page(first_page) + observer.assert_page(plugin) + write("all-observed-requests", {"requests": [row for _, row in observer.requests], "events": observer.events}) + finally: + observer.close() + except Exception as error: + cleanup_errors.append(str(error)) + try: + if session is not None: + write("shutdown", session.stop()) + shutdown_completed = True + except Exception as error: + cleanup_errors.append(str(error)) + finally: + for owner in list(driver.owned_processes.values()): + if owner.running(): + try: + driver.terminate_process_tree(owner.pid) + except Exception as error: + cleanup_errors.append(str(error)) + survivors = [owner.pid for owner in driver.owned_processes.values() if owner.running()] + if not survivors: + for path, name in ((args.app_local_data, driver.ACCEPTANCE_IDENTIFIER), + (args.app_roaming_data, driver.ACCEPTANCE_IDENTIFIER), (args.webview_data, "webview2")): + try: + driver.safe_remove_owned_directory(path, path.parent, name) + except Exception as error: + cleanup_errors.append(str(error)) + try: + if counter is not None: + counter.close() + write("counter-final", {"endpoint": counter.endpoint, "requests": counter.records, + "positiveControl": counter.controls, "closed": counter.closed}) + if counter.records: + cleanup_errors.append("literal URL counter received requests") + except Exception as error: + cleanup_errors.append(str(error)) + profiles_absent = not any(p.exists() for p in (args.app_local_data, args.app_roaming_data, args.webview_data)) + write("cleanup", {"ownedSurvivors": survivors, "errors": cleanup_errors, + "profilesAbsent": profiles_absent, "counterClosed": counter is None or counter.closed}) + if survivors or cleanup_errors or not profiles_absent: + raise RuntimeError(f"cleanup failures: {cleanup_errors}; survivors: {survivors}; profilesAbsent={profiles_absent}") + except Exception as error: + first_failure = first_failure or error + except Exception as error: + first_failure = first_failure or error + finally: + # Also runs when creating Playwright or writing first-failure evidence + # fails; server lifetime cannot depend on report serialization. + if counter is not None and not counter.closed: + try: + counter.close() + except Exception as error: + first_failure = first_failure or error + try: + with step(CASES[8]): + after = snapshot_resources(args.run_image) + write("resources-after", after) + write("resources-diff", resource_diff(baseline, after)) + assert_resources(baseline, after) + if plan is not None and sha256(confined(args.fixture_root, plan["archive"]["path"])) != archive_before: + raise AssertionError("fixture archive changed") + if package_before is not None and package_root.exists(): + assert_resources(package_before, snapshot_resources(package_root)) + if not shutdown_completed: + raise AssertionError("native workflow/shutdown did not complete before final resource comparison") + except Exception as error: + first_failure = first_failure or error + for owner in driver.owned_processes.values(): + owner.close() + driver.owned_processes.clear() + if first_failure: + raise first_failure + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for key in ("fixture-root", "host-identity", "executable", "run-image", "artifact-root", "report", "app-local-data", "app-roaming-data", "webview-data"): + parser.add_argument("--" + key, required=True, type=Path) + args = parser.parse_args() + for key, value in vars(args).items(): + setattr(args, key, Path(os.path.abspath(value))) + import acceptance_driver as driver + for path in (args.app_local_data, args.app_roaming_data, args.webview_data, args.fixture_root, args.artifact_root): + if path.exists(): + parser.error("run-owned inputs/profiles/evidence must be absent") + if (args.app_local_data != Path(os.environ["LOCALAPPDATA"]) / driver.ACCEPTANCE_IDENTIFIER + or args.app_roaming_data != Path(os.environ["APPDATA"]) / driver.ACCEPTANCE_IDENTIFIER or args.webview_data.name != "webview2"): + parser.error("unexpected acceptance profile identity") + # Progress lacks the normalized runner envelope; keep it with raw evidence + # so directory report aggregation consumes only final timing reports. + reporter = driver.Reporter(args.artifact_root / "progress.json") + code = 0 + try: + run(args, driver, reporter) + except Exception as error: + print(f"{type(error).__name__}: {error}", flush=True) + code = 1 + finally: + completed = {row["name"].split(" / ")[-1] for row in reporter.tests} + for name in CASES: + if name not in completed: + reporter.document_pending("controlled-ui1-literal / " + name, "Not reached; helpers cannot establish runtime acceptance.") + reporter.document_pending("complete-M10", "partial/pending: physical input/network faults, ui2/functions/ui3, arm64, publisher-signed complete packages and all other M10 remain unverified; all gates unchanged.") + driver.write_json(args.report, {"runner": "acceptance", "warnMs": 30_000, "maxMs": 120_000, + "complete": False, "controlledSubsetPassed": code == 0, "tests": reporter.tests}) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/text_window_acceptance_test.py b/scripts/plugin-host-acceptance/text_window_acceptance_test.py new file mode 100644 index 0000000000..dfcbd76934 --- /dev/null +++ b/scripts/plugin-host-acceptance/text_window_acceptance_test.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Failure-boundary helpers only; not evidence of literal rendering in Windows.""" +from __future__ import annotations + +import argparse +import contextlib +import copy +import io +import json +import os +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, Mock, patch + +import text_window_acceptance as acceptance +import text_window_fixture as fixture + +real_source_inventory = fixture.source_inventory + + +class TextWindowHelperTests(unittest.TestCase): + endpoint = "http://127.0.0.1:12345/literal-probe" + + def setUp(self): + # These helpers test identity comparisons, not repository disk speed or + # a specific checkout. Native acceptance still hashes the real sources. + for name, value in (("source_revision", "a" * 40), ("source_inventory", {"controlled-source": "b" * 64})): + owner = patch.object(fixture, name, return_value=value) + owner.start() + self.addCleanup(owner.stop) + + def test_external_or_ambiguous_counter_endpoints_rejected(self): + for value in ("https://example.invalid/literal-probe", "http://localhost:123/literal-probe", + self.endpoint + "?x", "http://user@127.0.0.1:123/literal-probe", "http://127.0.0.1/literal-probe"): + with self.assertRaises(ValueError): + fixture.declaration(value) + + def test_driver_progress_does_not_break_final_report_aggregation(self): + import acceptance_driver as driver + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + values = { + "fixture-root": root / "fixture", "host-identity": root / "identity.json", + "executable": root / "host.exe", "run-image": root / "image", + "artifact-root": root / "evidence", "report": root / "reports/acceptance.json", + "app-local-data": root / "local" / driver.ACCEPTANCE_IDENTIFIER, + "app-roaming-data": root / "roaming" / driver.ACCEPTANCE_IDENTIFIER, + "webview-data": root / "runtime/webview2", + } + argv = ["text_window_acceptance.py"] + for key, value in values.items(): + argv.extend(["--" + key, str(value)]) + def complete(_args, _driver, reporter): + for name in acceptance.CASES: + with reporter.step("controlled-ui1-literal / " + name): + pass + with patch.object(sys, "argv", argv), \ + patch.dict(os.environ, {"LOCALAPPDATA": str(root / "local"), "APPDATA": str(root / "roaming")}), \ + patch.object(acceptance, "run", side_effect=complete), contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(acceptance.main(), 0) + self.assertTrue((root / "evidence/progress.json").is_file()) + self.assertEqual(list((root / "reports").glob("*.json")), [values["report"]]) + generator = fixture.ROOT / ".agents/skills/write-stable-tests/scripts/generate-test-report.mjs" + result = subprocess.run(["node", str(generator), "--input-dir", str(root / "reports")], + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((root / "reports/index.html").is_file()) + self.assertTrue((root / "reports/acceptance.junit.xml").is_file()) + + def test_tampered_archive_cannot_be_adopted_by_updating_plan_hash(self): + parent = fixture.ROOT / ".artifacts/plugin-host-acceptance" + parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="text-window-fixture-test-", dir=parent) as temporary: + root = Path(temporary) / "inputs" + plan = fixture.generate(root, self.endpoint) + archive = root / plan["archive"]["path"] + archive.unlink() + manifest = fixture.declaration(self.endpoint) + manifest["permissions"].append("ui.web") + fixture.write_archive(archive, {"plugin.json": fixture.json_bytes(manifest)}) + plan["archive"]["sha256"] = fixture.sha256(archive) + (root / "plan.json").write_bytes(fixture.json_bytes(plan)) + with self.assertRaisesRegex(ValueError, "content changed"): + fixture.load_plan(root) + + def test_source_inventory_uses_one_deadline_for_host_and_streamed_third_party(self): + with patch.object(fixture, "host_source_inventory", return_value={}) as host, \ + patch.object(fixture.time, "monotonic", side_effect=[0, 301]), \ + patch.object(fixture.subprocess, "run") as command: + with self.assertRaisesRegex(TimeoutError, "300 seconds"): + real_source_inventory() + host.assert_called_once_with(deadline=300) + command.assert_not_called() + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "third_party/data.bin" + source.parent.mkdir() + source.write_bytes(b"controlled input") + result = SimpleNamespace(stdout=b"third_party/data.bin\0") + with patch.object(fixture, "ROOT", root), \ + patch.object(fixture, "host_source_inventory", return_value={}), \ + patch.object(fixture.subprocess, "run", return_value=result), \ + patch.object(fixture.time, "monotonic", side_effect=[0, 0, 0, 301]): + with self.assertRaisesRegex(TimeoutError, "300 seconds"): + real_source_inventory() + + def test_source_inventory_canonicalizes_import_alias_before_relative_keys(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + (root / "alias").mkdir() + scripts = root / "scripts/plugin-host-acceptance" + scripts.mkdir(parents=True) + names = ("text_window_fixture.py", "text_window_acceptance.py", "text_window_acceptance_test.py") + for name in names: + (scripts / name).write_bytes(b"controlled source") + # An import alias must produce the same keys as the canonical ROOT. + # No system drive mapping or link privilege is needed for this case. + alias = root / "alias/../scripts/plugin-host-acceptance/text_window_fixture.py" + with patch.object(fixture, "ROOT", root), patch.object(fixture, "__file__", str(alias)), \ + patch.object(fixture, "host_source_inventory", return_value={}), \ + patch.object(fixture.subprocess, "run", return_value=SimpleNamespace(stdout=b"")): + inventory = real_source_inventory() + self.assertEqual(sorted(inventory), ["scripts/plugin-host-acceptance/" + name for name in sorted(names)]) + self.assertEqual(len(set(inventory.values())), 1) + + def test_run_source_is_reusable_at_new_revision_but_rejects_later_drift(self): + parent = fixture.ROOT / ".artifacts/plugin-host-acceptance" + parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="text-window-fixture-test-", dir=parent) as temporary: + root = Path(temporary) / "inputs" + with patch.object(fixture, "source_revision", return_value="1" * 40): + plan = fixture.generate(root, self.endpoint) + self.assertEqual(plan["sourceCommit"], "1" * 40) + self.assertEqual(fixture.load_plan(root), plan) + with patch.object(fixture, "source_revision", return_value="2" * 40): + with self.assertRaisesRegex(ValueError, "identity changed"): + fixture.load_plan(root) + with patch.object(fixture, "source_revision", return_value="1" * 40), patch.object(fixture, "source_inventory", return_value={}): + with self.assertRaisesRegex(ValueError, "identity changed"): + fixture.load_plan(root) + + def observer(self): + context = Mock() + observer = acceptance.PassiveRequests(SimpleNamespace(contexts=[context])) + page = SimpleNamespace(context=context) + return observer, page + + def rows(self): + return [{"url": acceptance.TEXT_URL, "method": "GET", "resourceType": "document"}, + {"url": acceptance.BRIDGE_URL, "method": "GET", "resourceType": "script"}] + + def test_empty_or_late_observer_is_failure(self): + observer, page = self.observer() + for rows in ([], self.rows()[1:]): + observer.requests = [(page, row) for row in rows] + with self.assertRaisesRegex(AssertionError, "missing original request"): + observer.assert_page(page) + observer.close() + + def test_other_context_and_extra_ipc_external_requests_are_failure(self): + observer, page = self.observer() + observer.requests = [(page, row) for row in self.rows()] + page.context = Mock() + with self.assertRaisesRegex(AssertionError, "outside pre-observed"): + observer.assert_page(page) + page.context = observer.contexts[0] + for url in ("http://ipc.localhost/plugin%3Adeclarative-web%7Crequest", self.endpoint): + observer.requests = [(page, row) for row in self.rows() + [{"url": url, "method": "POST", "resourceType": "fetch"}]] + with self.assertRaises(AssertionError): + observer.assert_page(page) + observer.close() + + def test_dom_detects_entity_decoding_injection_and_extra_script(self): + result = {"url": acceptance.TEXT_URL, "text": fixture.literal_text(self.endpoint), "bodyTags": ["PRE"], + "dangerous": 0, "injected": False, "scripts": [{"src": acceptance.BRIDGE_URL, "text": ""}], + "context": {"pluginID": fixture.PLUGIN_ID, "windowID": fixture.WINDOW_ID}} + for key, value in (("text", result["text"].replace("<", "<")), ("bodyTags", ["PRE", "SCRIPT"]), + ("dangerous", 1), ("injected", True), ("scripts", result["scripts"] + [{"src": "", "text": "alert(1)"}])): + changed = copy.deepcopy(result) + changed[key] = value + with self.assertRaises(AssertionError): + acceptance.assert_dom(changed, self.endpoint) + + def test_counter_failure_path_closes_thread_and_socket(self): + counter = acceptance.Counter() + try: + counter.start() + raise ValueError("controlled failure") + except ValueError: + pass + finally: + counter.close() + self.assertTrue(counter.closed) + self.assertFalse(counter.thread.is_alive()) + self.assertEqual(counter.server.fileno(), -1) + + def test_observer_ui_side_effects_fail_and_cleanup_removes_listeners(self): + observer, page = self.observer() + observer.requests = [(page, row) for row in self.rows()] + dialog = Mock(type="alert", message="literal") + observer.on_dialog(dialog) + dialog.dismiss.assert_called_once() + with self.assertRaises(AssertionError): + observer.assert_page(page) + observer.close() + self.assertEqual(observer.contexts[0].remove_listener.call_count, 4) + + def test_first_identity_failure_survives_teardown_and_cleans_counter(self): + from acceptance_driver import Reporter + parent = fixture.ROOT / ".artifacts/plugin-host-acceptance" + parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="text-window-fixture-test-", dir=parent) as temporary: + root = Path(temporary) + (root / "image").mkdir() + args = SimpleNamespace(fixture_root=root / "inputs", artifact_root=root / "evidence", run_image=root / "image", + host_identity=root / "identity.json", executable=root / "image/host.exe", app_local_data=root / "local/app.lithe.windows.host-acceptance", + app_roaming_data=root / "roaming/app.lithe.windows.host-acceptance", webview_data=root / "runtime/webview2") + driver = MagicMock() + driver.owned_processes = {} + driver.ACCEPTANCE_IDENTIFIER = "app.lithe.windows.host-acceptance" + driver.sync_playwright.return_value.__exit__.side_effect = RuntimeError("observer teardown failed") + counter = acceptance.Counter() + reporter = Reporter() + with patch.object(acceptance, "Counter", return_value=counter), patch.object(acceptance, "verify_host", side_effect=ValueError("first identity failure")), contextlib.redirect_stdout(io.StringIO()): + with self.assertRaisesRegex(ValueError, "first identity failure"): + acceptance.run(args, driver, reporter) + driver.AppSession.assert_not_called() + self.assertTrue(counter.closed) + self.assertFalse(counter.thread.is_alive()) + row = next(row for row in reporter.tests if row["name"].endswith(" / resource-invariance")) + self.assertEqual(row["status"], "failed") + failure = next(call.args[1] for call in driver.write_json.call_args_list if call.args[0].name == "first-failure.json") + self.assertEqual(failure["message"], "first identity failure") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--case") + parser.add_argument("--report", type=Path) + args = parser.parse_args() + if args.case: + result = unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromName(args.case, sys.modules[__name__])) + return int(not result.wasSuccessful()) + if args.report is None: + parser.error("--report is required") + rows = [] + for name in unittest.defaultTestLoader.getTestCaseNames(TextWindowHelperTests): + started = time.monotonic() + status, details, code = "passed", "", None + command = [sys.executable, "-B", str(Path(__file__).resolve()), "--case", "TextWindowHelperTests." + name] + try: + result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10) + code = result.returncode + if code: + status, details = "failed", result.stdout + result.stderr + except subprocess.TimeoutExpired: + status, details = "timeout", "helper exceeded its ten-second subprocess deadline" + duration = round((time.monotonic() - started) * 1000, 3) + if duration >= 10_000: + status = "timeout" + rows.append({"name": name, "suite": "plugin-host-acceptance / text-window-helpers-only", "status": status, + "durationMs": duration, "maxMs": 10_000, "details": details, "command": command, "exitCode": code}) + print(f"{status.upper()} {name} ({duration} ms) {details}", flush=True) + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_bytes(fixture.json_bytes({"runner": "acceptance", "warnMs": 2000, "maxMs": 10_000, "tests": rows})) + return int(any(row["status"] != "passed" for row in rows)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/text_window_fixture.py b/scripts/plugin-host-acceptance/text_window_fixture.py new file mode 100644 index 0000000000..dfa62b0691 --- /dev/null +++ b/scripts/plugin-host-acceptance/text_window_fixture.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Complete disposable unsigned API3 ui:1 package; never a signed-package proxy.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import subprocess +import time +import zipfile +from pathlib import Path +from urllib.parse import urlsplit + +from build_fixture import ROOT, confined, json_bytes, ordinary_tree, write_archive +from settings_functions_fixture import sha256, source_revision +from web_bridge_fallback_fixture import source_inventory as host_source_inventory + +PLUGIN_ID = "io.github.lithe-acceptance.literal-text" +WINDOW_ID = PLUGIN_ID + ".window" +DISPLAY_NAME = "Lithe Literal Text Acceptance" + + +def source_inventory() -> dict: + deadline = time.monotonic() + 300 + inventory = host_source_inventory(deadline=deadline) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("full text-window source inventory exceeded 300 seconds") + result = subprocess.run(["git", "ls-files", "-z", "--", "third_party"], cwd=ROOT, + capture_output=True, check=True, timeout=min(15, remaining)) + def bounded_hash(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while True: + if time.monotonic() >= deadline: + raise TimeoutError("full text-window source inventory exceeded 300 seconds") + chunk = stream.read(1024 * 1024) + if not chunk: + return digest.hexdigest() + digest.update(chunk) + for name in result.stdout.decode().split("\0"): + if name and (ROOT / name).is_file(): + inventory[name] = bounded_hash(ROOT / name) + for name in ("text_window_fixture.py", "text_window_acceptance.py", "text_window_acceptance_test.py"): + # ROOT is canonical; resolve a SUBST/alias import before deriving its key. + path = Path(__file__).resolve().with_name(name) + inventory[path.relative_to(ROOT).as_posix()] = bounded_hash(path) + return dict(sorted(inventory.items())) + + +def literal_text(endpoint: str) -> str: + parsed = urlsplit(endpoint) + if (parsed.scheme != "http" or parsed.hostname != "127.0.0.1" or not parsed.port + or parsed.path != "/literal-probe" or parsed.query or parsed.fragment or parsed.username): + raise ValueError("probe must be one owned loopback HTTP endpoint") + return ("中文与换行\n单双引号:' \" & 实体:<script> & '\n" + "\n" + f"\n" + f"download\n" + f"fetch(\"{endpoint}\"); window.open('{endpoint}'); https://example.invalid/literal\n" + "
    最后一行\n")
    +
    +
    +def declaration(endpoint: str) -> dict:
    +    return {"schemaVersion": 2, "id": PLUGIN_ID, "displayName": DISPLAY_NAME,
    +            "version": "1.0.0", "pluginAPIVersion": 3, "requires": {"ui": 1},
    +            "vendor": {"id": "io.github.lithe-acceptance", "displayName": "Unsigned controlled fixture",
    +                       "signatureRequirement": "none"},
    +            "permissions": ["ui.contribute"], "activationEvents": ["onView:" + WINDOW_ID],
    +            "languages": [], "ui": {"toolWindows": [{"id": WINDOW_ID, "title": DISPLAY_NAME,
    +                "placement": "right", "order": 300, "text": literal_text(endpoint)}]}}
    +
    +
    +def check_output(root: Path) -> None:
    +    parent = ROOT / ".artifacts/plugin-host-acceptance"
    +    if parent.resolve() not in root.resolve().parents or not root.resolve().relative_to(parent.resolve()).parts[0].startswith("text-window-fixture"):
    +        raise ValueError("use a run-owned text-window-fixture below .artifacts/plugin-host-acceptance")
    +    for path in (root, *root.parents):
    +        if path.is_symlink() or path.is_junction():
    +            raise ValueError("fixture path cannot traverse a link")
    +
    +
    +def generate(root: Path, endpoint: str) -> dict:
    +    check_output(root)
    +    manifest = declaration(endpoint)
    +    revision = source_revision()
    +    inventory = source_inventory()
    +    root.mkdir(parents=True, exist_ok=False)
    +    archive = root / "literal-text.zip"
    +    write_archive(archive, {"plugin.json": json_bytes(manifest)})
    +    (root / "workspace").mkdir()
    +    plan = {"schemaVersion": 1, "kind": "controlled-unsigned-ui1-literal-text", "pluginId": PLUGIN_ID,
    +            "sourceCommit": revision, "sourceInventory": inventory,
    +            "generatorSha256": sha256(Path(__file__)), "endpoint": endpoint,
    +            "archive": {"path": archive.name, "sha256": sha256(archive)}, "requires": {"ui": 1}}
    +    (root / "plan.json").write_bytes(json_bytes(plan))
    +    return plan
    +
    +
    +def load_plan(root: Path) -> dict:
    +    check_output(root)
    +    ordinary_tree(root)
    +    plan = json.loads((root / "plan.json").read_text(encoding="utf-8"))
    +    expected = {"schemaVersion": 1, "kind": "controlled-unsigned-ui1-literal-text", "pluginId": PLUGIN_ID,
    +                "sourceCommit": source_revision(), "sourceInventory": source_inventory(),
    +                "generatorSha256": sha256(Path(__file__)), "endpoint": plan["endpoint"],
    +                "archive": {"path": "literal-text.zip", "sha256": sha256(root / "literal-text.zip")}, "requires": {"ui": 1}}
    +    if plan != expected:
    +        raise ValueError("fixture source/manifest/archive identity changed")
    +    with zipfile.ZipFile(confined(root, plan["archive"]["path"])) as archive:
    +        if archive.namelist() != ["plugin.json"] or archive.read("plugin.json") != json_bytes(declaration(plan["endpoint"])):
    +            raise ValueError("complete controlled fixture content changed")
    +    return plan
    +
    +
    +if __name__ == "__main__":
    +    parser = argparse.ArgumentParser(description=__doc__)
    +    parser.add_argument("--output", required=True, type=Path)
    +    parser.add_argument("--endpoint", required=True)
    +    args = parser.parse_args()
    +    print(json.dumps(generate(args.output, args.endpoint), indent=2))
    diff --git a/scripts/plugin-host-acceptance/vcs-move-diagnostic-worker.mjs b/scripts/plugin-host-acceptance/vcs-move-diagnostic-worker.mjs
    new file mode 100644
    index 0000000000..d25ccac522
    --- /dev/null
    +++ b/scripts/plugin-host-acceptance/vcs-move-diagnostic-worker.mjs
    @@ -0,0 +1,263 @@
    +// Explicit source diagnostic only. The Python supervisor owns the hard case/suite
    +// deadlines and Windows job before releasing this process. No product is started.
    +import assert from 'node:assert/strict';
    +import { spawn, execFileSync } from 'node:child_process';
    +import { createHash } from 'node:crypto';
    +import { createServer } from 'node:net';
    +import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
    +import { join, relative, resolve } from 'node:path';
    +import { pathToFileURL } from 'node:url';
    +
    +const hash = bytes => createHash('sha256').update(bytes).digest('hex');
    +const SOURCE = Buffer.from('source-original\0bytes\r\n');
    +
    +export function bounded(promise, ms, label, timers = globalThis) {
    +  let timer;
    +  return Promise.race([promise, new Promise((_, reject) => {
    +    timer = timers.setTimeout(() => reject(new Error(`${label} deadline`)), ms);
    +  })]).finally(() => timers.clearTimeout(timer));
    +}
    +
    +export function snapshotTree(root) {
    +  if (!existsSync(root)) return [];
    +  const rows = [];
    +  function visit(path) {
    +    const stat = lstatSync(path, { bigint: true });
    +    assert(!stat.isSymbolicLink(), 'diagnostic fixture must not traverse links');
    +    const row = { path: relative(root, path).replaceAll('\\', '/') || '.',
    +      kind: stat.isDirectory() ? 'directory' : 'file',
    +      identity: { dev: String(stat.dev), ino: String(stat.ino), birthtimeNs: String(stat.birthtimeNs) } };
    +    if (stat.isFile()) {
    +      const bytes = readFileSync(path);
    +      Object.assign(row, { bytes: bytes.length, sha256: hash(bytes), base64: bytes.toString('base64') });
    +    }
    +    rows.push(row);
    +    if (stat.isDirectory()) for (const name of readdirSync(path).sort()) if (name !== '.svn') visit(join(path, name));
    +  }
    +  visit(root);
    +  return rows;
    +}
    +
    +function item(snapshot, path) { return snapshot.tree.find(row => row.path === path); }
    +function schedule(snapshot, path, expected) {
    +  const entry = [...snapshot.statusXml.matchAll(/([\s\S]*?)<\/entry>/g)]
    +    .find(match => match[1].replaceAll('\\', '/') === path);
    +  assert(entry, `missing WC status for ${path}`);
    +  assert.match(entry[2], new RegExp(`\\bitem="${expected}"`), `${path} WC schedule`);
    +}
    +
    +export async function finalize(record, actions) {
    +  for (const [name, action] of Object.entries(actions)) {
    +    try { record[name] = await action(); }
    +    catch (error) { record[`${name}Error`] = String(error); }
    +  }
    +}
    +
    +export function assertObservation(name, before, after, reply) {
    +  const source = item(before, 'source.txt');
    +  assert(source && source.sha256 === hash(SOURCE), 'original source bytes required');
    +  if (name === 'missing-leaf') {
    +    assert.deepEqual(reply.result, {});
    +    assert(!reply.error);
    +    assert(!item(after, 'source.txt'));
    +    assert.equal(item(after, 'target.txt')?.kind, 'file');
    +    assert.equal(item(after, 'target.txt')?.sha256, source.sha256);
    +    assert(!item(after, 'target.txt/source.txt'));
    +    schedule(after, 'source.txt', 'deleted');
    +    schedule(after, 'target.txt', 'added');
    +    return { diagnosis: 'exact-leaf-success', exactLeaf: true, safetyPass: false,
    +      limitation: 'single unopposed diagnostic success does not establish atomic no-replacement' };
    +  }
    +  if (name === 'target-file') {
    +    assert(reply.error, 'Provider must report the observed SVN obstruction failure');
    +    assert.match(JSON.stringify(reply.error), /E155033/);
    +    assert.deepEqual(item(after, 'source.txt'), source, 'failure preserves source bytes and identity');
    +    assert.deepEqual(item(after, 'target.txt'), item(before, 'target.txt'), 'failure preserves intruder bytes and identity');
    +    assert(!item(after, 'target.txt/source.txt'));
    +    schedule(before, 'target.txt', 'unversioned');
    +    schedule(after, 'source.txt', 'normal');
    +    schedule(after, 'target.txt', 'added');
    +    return { diagnosis: 'failure-with-wc-metadata-side-effect', exactLeaf: false, safetyPass: false,
    +      refreshRequired: true, wcMetadataChanged: true };
    +  }
    +  assert.equal(name, 'target-directory');
    +  assert.deepEqual(reply.result, {});
    +  assert(!reply.error);
    +  assert(!item(after, 'source.txt'));
    +  assert.equal(item(after, 'target.txt')?.kind, 'directory');
    +  assert.deepEqual(item(after, 'target.txt')?.identity, item(before, 'target.txt')?.identity);
    +  assert.equal(item(after, 'target.txt/source.txt')?.sha256, source.sha256);
    +  schedule(after, 'source.txt', 'deleted');
    +  schedule(after, 'target.txt/source.txt', 'added');
    +  return { diagnosis: 'provider-success-wrong-endpoint', exactLeaf: false, safetyPass: false, refreshRequired: true };
    +}
    +
    +function peer(child, wire, fail) {
    +  let buffer = Buffer.alloc(0), sequence = 0;
    +  const pending = new Map();
    +  child.stdout.on('data', bytes => {
    +    try {
    +      buffer = Buffer.concat([buffer, bytes]);
    +      assert(buffer.length <= 4e6, 'Provider output budget');
    +      for (;;) {
    +        const header = buffer.indexOf('\r\n\r\n');
    +        if (header < 0) break;
    +        const match = /Content-Length: (\d+)/i.exec(buffer.subarray(0, header).toString());
    +        assert(match && +match[1] <= 4e6);
    +        const end = header + 4 + +match[1];
    +        if (buffer.length < end) break;
    +        const message = JSON.parse(buffer.subarray(header + 4, end));
    +        buffer = buffer.subarray(end);
    +        wire.push({ direction: 'received', message });
    +        pending.get(message.id)?.resolve(message);
    +        pending.delete(message.id);
    +      }
    +    } catch (error) { fail(error); }
    +  });
    +  child.on('error', fail);
    +  child.on('close', () => { for (const request of pending.values()) request.reject(new Error('Provider exited during request')); pending.clear(); });
    +  const send = message => {
    +    const bytes = Buffer.from(JSON.stringify(message));
    +    wire.push({ direction: 'sent', message });
    +    child.stdin.write(Buffer.concat([Buffer.from(`Content-Length: ${bytes.length}\r\n\r\n`), bytes]));
    +  };
    +  return { request(method, params) {
    +    const id = ++sequence;
    +    const response = new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
    +    send({ jsonrpc: '2.0', id, method, params });
    +    return bounded(response, 12000, method).finally(() => pending.delete(id));
    +  }, notify(method, params) { send({ jsonrpc: '2.0', method, params }); } };
    +}
    +
    +export async function diagnose(inputs, name, root, runtime, report) {
    +  const record = { name, events: [], wire: [], commands: [], status: 'error', safetyPass: false };
    +  const checkpoint = () => writeFileSync(report, JSON.stringify(record, null, 2));
    +  let child, server, exitPromise, asynchronousError;
    +  const sockets = new Set();
    +  const svn = join(inputs.svnRoot, 'bin/svn.exe');
    +  const wc = join(root, 'wc');
    +  const env = { ...process.env, APPDATA: join(root, 'appdata'), LOCALAPPDATA: join(root, 'localappdata') };
    +  const started = performance.now();
    +  const run = (exe, args, cwd = root) => {
    +    const start = performance.now();
    +    try {
    +      const stdout = execFileSync(exe, args, { cwd, env, timeout: 8000, maxBuffer: 4e6, encoding: 'utf8', windowsHide: true });
    +      record.commands.push({ exe, args, exitCode: 0, durationMs: performance.now() - start, stdout });
    +      return stdout;
    +    } catch (error) {
    +      record.commands.push({ exe, args, exitCode: error.status, durationMs: performance.now() - start,
    +        stdout: String(error.stdout), stderr: String(error.stderr), error: String(error) });
    +      throw error;
    +    }
    +  };
    +  const snapshot = () => ({ tree: snapshotTree(wc),
    +    statusXml: run(svn, ['status', '--xml', '--verbose', '--no-ignore'], wc),
    +    infoXml: run(svn, ['info', '--xml', '--depth', 'infinity'], wc) });
    +  try {
    +    assert(!existsSync(root), 'exclusive fresh case directory');
    +    mkdirSync(root); mkdirSync(env.APPDATA); mkdirSync(env.LOCALAPPDATA);
    +    run(join(inputs.svnRoot, 'bin/svnadmin.exe'), ['create', join(root, 'repo')]);
    +    run(svn, ['--non-interactive', 'checkout', pathToFileURL(join(root, 'repo')).href, wc]);
    +    writeFileSync(join(wc, 'source.txt'), SOURCE);
    +    run(svn, ['add', 'source.txt'], wc); run(svn, ['commit', '-m', 'diagnostic initial'], wc);
    +    record.initial = snapshot();
    +    checkpoint();
    +    let gated = false;
    +    server = createServer(socket => {
    +      sockets.add(socket); socket.on('close', () => sockets.delete(socket));
    +      socket.on('error', error => { asynchronousError ??= error; });
    +      let input = '';
    +      socket.on('data', bytes => {
    +        try {
    +          input += bytes.toString(); assert(input.length < 65536, 'gate event budget');
    +          for (;;) {
    +            const newline = input.indexOf('\n'); if (newline < 0) break;
    +            const event = JSON.parse(input.slice(0, newline)); input = input.slice(newline + 1);
    +            record.events.push(event);
    +            if (event.phase === 'before-svn') {
    +              if (event.argv.includes('move')) {
    +                assert(!gated); gated = true;
    +                assert.deepEqual(event.argv.slice(-2), ['source.txt@', 'target.txt']);
    +                assert(!existsSync(join(wc, 'target.txt')));
    +                record.beforeInjection = snapshot();
    +                checkpoint();
    +                if (name === 'target-file') writeFileSync(join(wc, 'target.txt'), Buffer.from('intruder-do-not-overwrite\0'), { flag: 'wx' });
    +                if (name === 'target-directory') mkdirSync(join(wc, 'target.txt'));
    +                record.before = snapshot();
    +                record.events.push({ phase: 'injected-after-provider-status-before-svn-start', name });
    +                checkpoint();
    +              }
    +              socket.write('release\n');
    +            }
    +          }
    +        } catch (error) { asynchronousError ??= error; socket.destroy(); }
    +      });
    +    });
    +    await bounded(new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }), 3000, 'gate listen');
    +    child = spawn(inputs.node, [join(runtime, 'svn-provider.mjs'), '--stdio'], { cwd: root,
    +      env: { ...env, R16_REAL_SVN: svn, R16_GATE_PORT: String(server.address().port) },
    +      windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
    +    exitPromise = new Promise(resolve => child.once('close', (code, signal) => resolve({ code, signal })));
    +    record.providerPid = child.pid; record.stderr = '';
    +    child.stderr.on('data', bytes => {
    +      record.stderr += bytes.toString();
    +      if (record.stderr.length > 4e6) { asynchronousError ??= new Error('stderr budget'); child.kill(); }
    +    });
    +    child.stdin.on('error', error => { asynchronousError ??= error; });
    +    const rpc = peer(child, record.wire, error => { asynchronousError ??= error; child.kill(); });
    +    record.initialize = await rpc.request('initialize', { processId: process.pid, capabilities: { experimental: { litheVcs: { versions: [1] } } } });
    +    assert.equal(record.initialize.result.capabilities.experimental.litheVcs.capabilities.fileOperations, 1);
    +    rpc.notify('initialized', {});
    +    const params = { root: { path: wc }, tool: { executable: inputs.gate } };
    +    record.statusReply = await rpc.request('lithe/vcs/status', params);
    +    assert(!record.statusReply.error, 'actual Provider status must precede the injection gate');
    +    record.reply = await rpc.request('lithe/vcs/fileOperation', { ...params, kind: 'move', from: 'source.txt', to: 'target.txt' });
    +    if (asynchronousError) throw asynchronousError;
    +    assert(gated);
    +    assert(record.events.some(event => event.phase === 'svn-exited'));
    +    record.after = snapshot();
    +    record.observation = assertObservation(name, record.before, record.after, record.reply);
    +    record.shutdown = await rpc.request('shutdown', null); rpc.notify('exit'); child.stdin.end();
    +    record.providerExit = await bounded(exitPromise, 3000, 'provider exit');
    +    assert.equal(record.providerExit.code, 0);
    +    record.status = 'diagnosis-matched';
    +  } catch (error) { record.error = error.stack; }
    +  finally {
    +    // Evidence capture and every cleanup run even when an assertion/handler throws.
    +    await finalize(record, {
    +      final: () => existsSync(wc) ? snapshot() : null,
    +      socketCleanup: async () => {
    +        for (const socket of sockets) socket.destroy();
    +        if (server) await bounded(new Promise(resolve => server.close(resolve)), 3000, 'gate close');
    +        return { sockets: sockets.size, closed: true };
    +      },
    +      processCleanup: async () => {
    +        if (!child) return null;
    +        if (child.exitCode === null && child.signalCode === null) child.kill();
    +        return bounded(exitPromise, 3000, 'cleanup provider exit');
    +      },
    +    });
    +    record.durationMs = performance.now() - started;
    +    record.processTreeOwner = 'external Windows job; supervisor terminates and verifies zero active members on every exit';
    +    writeFileSync(report, JSON.stringify(record, null, 2));
    +  }
    +  return record.status === 'diagnosis-matched' && !record.finalError && !record.socketCleanupError && !record.processCleanupError ? 0 : 1;
    +}
    +
    +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
    +  assert.equal(process.env.LITHE_VCS_DIAGNOSTIC_OWNER, 'windows-job-supervisor',
    +    'use the Python diagnostic entry with external case/suite deadlines');
    +  if (process.argv[2] === '--snapshot') {
    +    const [, input, wc, report] = process.argv.slice(2);
    +    const inputs = JSON.parse(readFileSync(input, 'utf8').replace(/^\uFEFF/, ''));
    +    const record = { tree: snapshotTree(wc), operationOutcome: 'unknown', refreshRequired: true };
    +    try {
    +      record.statusXml = execFileSync(join(inputs.svnRoot, 'bin/svn.exe'), ['status', '--xml', '--verbose', '--no-ignore'],
    +        { cwd: wc, timeout: 8000, maxBuffer: 4e6, encoding: 'utf8', windowsHide: true });
    +    } catch (error) { record.statusError = String(error); process.exitCode = 1; }
    +    finally { writeFileSync(report, JSON.stringify(record, null, 2)); }
    +  } else {
    +    const [input, name, root, runtime, report] = process.argv.slice(2);
    +    process.exitCode = await diagnose(JSON.parse(readFileSync(input, 'utf8').replace(/^\uFEFF/, '')), name, root, runtime, report);
    +  }
    +}
    diff --git a/scripts/plugin-host-acceptance/vcs-move-diagnostic_test.mjs b/scripts/plugin-host-acceptance/vcs-move-diagnostic_test.mjs
    new file mode 100644
    index 0000000000..a044172eec
    --- /dev/null
    +++ b/scripts/plugin-host-acceptance/vcs-move-diagnostic_test.mjs
    @@ -0,0 +1,69 @@
    +// Offline deterministic assertions. No installed SVN, network, or product.
    +import { test } from 'node:test';
    +import assert from 'node:assert/strict';
    +import { createHash } from 'node:crypto';
    +import { assertObservation, bounded, finalize } from './vcs-move-diagnostic-worker.mjs';
    +
    +const source = { path: 'source.txt', kind: 'file', identity: { ino: '1' },
    +  sha256: createHash('sha256').update(Buffer.from('source-original\0bytes\r\n')).digest('hex') };
    +const target = { path: 'target.txt', kind: 'file', identity: { ino: '2' }, sha256: 'intruder' };
    +const directory = { path: 'target.txt', kind: 'directory', identity: { ino: '3' } };
    +const state = (tree, entries) => ({ tree, statusXml: '' + Object.entries(entries)
    +  .map(([path, item]) => ``).join('') + '' });
    +const failed = { error: { message: 'svn: E155033: obstruction' } };
    +const success = { result: {} };
    +
    +test('missing leaf checks exact endpoint and both WC schedules without granting safety', () => {
    +  const before = state([source], { 'source.txt': 'normal' });
    +  const after = state([{ ...source, path: 'target.txt' }], { 'source.txt': 'deleted', 'target.txt': 'added' });
    +  assert.equal(assertObservation('missing-leaf', before, after, success).safetyPass, false);
    +  assert.throws(() => assertObservation('missing-leaf', before, { ...after, tree: [{ ...source, path: 'target.txt/source.txt' }] }, success));
    +  assert.throws(() => assertObservation('missing-leaf', before, { ...after, statusXml: '' }, success));
    +});
    +
    +test('file obstruction requires unchanged source and target identities AND added WC schedule', () => {
    +  const before = state([source, target], { 'source.txt': 'normal', 'target.txt': 'unversioned' });
    +  const after = state([source, target], { 'source.txt': 'normal', 'target.txt': 'added' });
    +  const result = assertObservation('target-file', before, after, failed);
    +  assert.equal(result.wcMetadataChanged, true); assert.equal(result.safetyPass, false);
    +  assert.throws(() => assertObservation('target-file', before, before, failed));
    +  assert.throws(() => assertObservation('target-file', before, { ...after, tree: [source, { ...target, identity: { ino: 'replacement' } }] }, failed));
    +  assert.throws(() => assertObservation('target-file', before, { ...after, tree: [{ ...source, sha256: 'corrupt' }, target] }, failed));
    +  assert.throws(() => assertObservation('target-file', before, after, success));
    +});
    +
    +test('directory obstruction records successful reply as wrong endpoint counterexample', () => {
    +  const before = state([source, directory], {});
    +  const after = state([directory, { ...source, path: 'target.txt/source.txt' }],
    +    { 'source.txt': 'deleted', 'target.txt/source.txt': 'added' });
    +  const result = assertObservation('target-directory', before, after, success);
    +  assert.equal(result.exactLeaf, false); assert.equal(result.safetyPass, false);
    +  assert.throws(() => assertObservation('target-directory', before, { ...after, tree: [{ ...directory, identity: { ino: 'new' } }, after.tree[1]] }, success));
    +  assert.throws(() => assertObservation('target-directory', before, after, failed));
    +});
    +
    +test('injected watchdog fires and clears its timer without wall clock sleeps', async () => {
    +  let fire, cleared;
    +  const timers = { setTimeout(callback, ms) { assert.equal(ms, 10); fire = callback; return 7; },
    +    clearTimeout(id) { cleared = id; } };
    +  const pending = bounded(new Promise(() => {}), 10, 'case', timers);
    +  fire(); await assert.rejects(pending, /case deadline/); assert.equal(cleared, 7);
    +  assert.equal(await bounded(Promise.resolve('done'), 10, 'case', timers), 'done');
    +  assert.equal(cleared, 7);
    +});
    +
    +test('assertion and snapshot errors still run every outer finally cleanup and preserve evidence', async () => {
    +  const record = {}, order = [];
    +  try { assert.fail('injected endpoint assertion'); }
    +  catch (error) { record.error = error.message; }
    +  finally {
    +    await finalize(record, {
    +      final() { order.push('snapshot'); throw Error('SVN status exception'); },
    +      socketCleanup() { order.push('sockets'); throw Error('socket failure'); },
    +      processCleanup() { order.push('job-owned-provider'); return { exited: true }; },
    +    });
    +  }
    +  assert.deepEqual(order, ['snapshot', 'sockets', 'job-owned-provider']);
    +  assert.match(record.error, /endpoint/); assert.match(record.finalError, /status/);
    +  assert.match(record.socketCleanupError, /socket/); assert.equal(record.processCleanup.exited, true);
    +});
    diff --git a/scripts/plugin-host-acceptance/vcs_move_diagnostic.py b/scripts/plugin-host-acceptance/vcs_move_diagnostic.py
    new file mode 100644
    index 0000000000..75760d34c0
    --- /dev/null
    +++ b/scripts/plugin-host-acceptance/vcs_move_diagnostic.py
    @@ -0,0 +1,447 @@
    +#!/usr/bin/env python3
    +"""Opt-in fixed-source SVN counterexample diagnostic, never product acceptance.
    +
    +Usage: python vcs_move_diagnostic.py --inputs inputs.json --output 
    +Inputs: node, powershell, provider, sdk, sourceIdentity, svnZip, svnRoot, gate, gateSource.
    +All paths are explicit absolute paths. No downloads, installed-SVN lookup, Cargo,
    +package signing, production capability change, or automatic working-copy repair.
    +sourceIdentity is the sealed R16 source-identity.json; gate/gateSource are its
    +diagnostic SvnGate.exe/C# input, not an upstream or formal Provider package.
    +The gate is after actual Provider status and before SVN launch; the fixed
    +Provider's tracked move handler has no destination precheck.
    +"""
    +from __future__ import annotations
    +
    +import argparse
    +import ctypes
    +import hashlib
    +import html
    +import json
    +import os
    +from pathlib import Path
    +import shutil
    +import stat
    +import subprocess
    +import sys
    +import tempfile
    +import time
    +import zipfile
    +from xml.etree import ElementTree as ET
    +
    +CASES = ("missing-leaf", "target-file", "target-directory")
    +PROVIDER = "3a558be049f47a3ebe24ae770faf7999df883c29"
    +SDK = "54598ae6e0b9fd71a07b77e1fea6917ba8897a6f"
    +ZIP_HASH = "98bb798dd6c70cc875b9bdacd600594351cf88f0a39a831a5055df061c871bb5"
    +SOURCE_HASH = "dcc9058e05de3eaa056373457e3d74752b76d22e70e55fa566f799079c37f60d"
    +GATE_HASH = "171f4006d9f8de03306c5bf3c5d8b73d56b96e596d4b819a0d6e570b61b9f38e"
    +GATE_SOURCE_HASH = "ec75d153868e3ce7afa576a9284eb5e680c5b1d0f53c003ddb62efcc167c8d77"
    +INPUT_KEYS = ("node", "powershell", "provider", "sdk", "sourceIdentity", "svnZip", "svnRoot", "gate", "gateSource")
    +
    +
    +def digest(path):
    +    return hashlib.sha256(Path(path).read_bytes()).hexdigest()
    +
    +
    +def write_json(path, value):
    +    Path(path).write_text(json.dumps(value, indent=2), encoding="utf-8")
    +
    +
    +def budget(deadline, case_seconds, now=time.monotonic):
    +    remaining = deadline - now()
    +    if remaining <= 0:
    +        raise TimeoutError("suite hard deadline exhausted")
    +    return min(case_seconds, remaining)
    +
    +
    +def supervised(command, seconds, log, owner_factory, now=time.monotonic):
    +    """Owner cleanup runs even on runner exceptions and failed assertions."""
    +    started = now()
    +    owner = None
    +    result = {"command": command, "timeoutSeconds": seconds}
    +    try:
    +        owner = owner_factory()
    +        result["exitCode"] = owner.run(command, seconds, log)
    +        if result["exitCode"] != 0:
    +            raise RuntimeError(f"worker exited {result['exitCode']}; see {log}")
    +    except Exception as error:
    +        result["error"] = str(error)
    +        if isinstance(error, TimeoutError):
    +            result["exitCode"] = 124
    +    finally:
    +        try:
    +            result["cleanup"] = owner.close() if owner else {"notStarted": True}
    +        except Exception as error:
    +            result["cleanupError"] = str(error)
    +        result["durationMs"] = (now() - started) * 1000
    +    result["status"] = "failed" if "error" in result or "cleanupError" in result else "passed"
    +    return result
    +
    +
    +def remove_owned_tree(root, seconds=5):
    +    """Remove only the newly allocated fixture, including SVN read-only revs."""
    +    root = Path(root).resolve()
    +    deadline = time.monotonic() + seconds
    +
    +    def writable_retry(function, path, error):
    +        target = Path(path).resolve()
    +        if root not in target.parents or time.monotonic() >= deadline:
    +            raise error
    +        os.chmod(target, stat.S_IWRITE | stat.S_IREAD)
    +        while True:
    +            try:
    +                function(target)
    +                return
    +            except OSError as failure:
    +                if getattr(failure, "winerror", None) != 32 or time.monotonic() >= deadline:
    +                    raise
    +                # Windows can briefly retain a file during kernel teardown.
    +                # Retry only this owned path and sharing violation, with a deadline.
    +                time.sleep(0.01)
    +
    +    shutil.rmtree(root, onexc=writable_retry)
    +    return not root.exists()
    +
    +
    +class WindowsJob:
    +    """One non-inherited kernel job handle owns a gated Python worker and descendants.
    +
    +    Same KILL_ON_JOB_CLOSE mechanism as invoke-windows-bun-install.ps1. The
    +    trusted Python worker cannot launch the requested command until assignment.
    +    No PID/name based termination, breakaway permission, or tree-scan race.
    +    """
    +    def __init__(self):
    +        from ctypes import wintypes as w
    +        class Basic(ctypes.Structure):
    +            _fields_ = [("user", ctypes.c_int64), ("job", ctypes.c_int64), ("flags", w.DWORD),
    +                        ("minimum", ctypes.c_size_t), ("maximum", ctypes.c_size_t),
    +                        ("active", w.DWORD), ("affinity", ctypes.c_size_t),
    +                        ("priority", w.DWORD), ("scheduling", w.DWORD)]
    +        class IO(ctypes.Structure):
    +            _fields_ = [(name, ctypes.c_uint64) for name in ("ro", "wo", "oo", "rb", "wb", "ob")]
    +        class Limits(ctypes.Structure):
    +            _fields_ = [("basic", Basic), ("io", IO), ("pm", ctypes.c_size_t),
    +                        ("jm", ctypes.c_size_t), ("pp", ctypes.c_size_t), ("pj", ctypes.c_size_t)]
    +        class Accounting(ctypes.Structure):
    +            _fields_ = [("u", ctypes.c_int64), ("k", ctypes.c_int64), ("pu", ctypes.c_int64),
    +                        ("pk", ctypes.c_int64), ("faults", w.DWORD), ("total", w.DWORD),
    +                        ("active", w.DWORD), ("terminated", w.DWORD)]
    +        self.accounting = Accounting
    +        self.k = ctypes.WinDLL("kernel32", use_last_error=True)
    +        signatures = {
    +            "CreateJobObjectW": ([ctypes.c_void_p, w.LPCWSTR], w.HANDLE),
    +            "SetInformationJobObject": ([w.HANDLE, ctypes.c_int, ctypes.c_void_p, w.DWORD], w.BOOL),
    +            "AssignProcessToJobObject": ([w.HANDLE, w.HANDLE], w.BOOL),
    +            "TerminateJobObject": ([w.HANDLE, w.UINT], w.BOOL),
    +            "QueryInformationJobObject": ([w.HANDLE, ctypes.c_int, ctypes.c_void_p, w.DWORD, ctypes.c_void_p], w.BOOL),
    +            "CloseHandle": ([w.HANDLE], w.BOOL),
    +            "OpenProcess": ([w.DWORD, w.BOOL, w.DWORD], w.HANDLE),
    +            "WaitForSingleObject": ([w.HANDLE, w.DWORD], w.DWORD),
    +        }
    +        for name, (args, ret) in signatures.items():
    +            getattr(self.k, name).argtypes = args
    +            getattr(self.k, name).restype = ret
    +        self.handle = self.k.CreateJobObjectW(None, None)
    +        self.process = None
    +        if not self.handle:
    +            raise ctypes.WinError(ctypes.get_last_error())
    +        limits = Limits()
    +        limits.basic.flags = 0x2000
    +        if not self.k.SetInformationJobObject(self.handle, 9, ctypes.byref(limits), ctypes.sizeof(limits)):
    +            self.k.CloseHandle(self.handle)
    +            raise ctypes.WinError(ctypes.get_last_error())
    +
    +    def run(self, command, seconds, log):
    +        with Path(log).open("wb") as stream:
    +            self.process = subprocess.Popen([sys.executable, str(Path(__file__).resolve()), "--exec", *command],
    +                                            stdin=subprocess.PIPE, stdout=stream, stderr=subprocess.STDOUT,
    +                                            env={**os.environ, "LITHE_VCS_DIAGNOSTIC_OWNER": "windows-job-supervisor"},
    +                                            creationflags=0x08000000)
    +            if not self.k.AssignProcessToJobObject(self.handle, int(self.process._handle)):
    +                # The root has not been released and cannot have descendants.
    +                self.process.kill()
    +                self.process.wait(timeout=2)
    +                raise ctypes.WinError(ctypes.get_last_error())
    +            try:
    +                self.process.communicate(b"release\n", timeout=seconds)
    +            except subprocess.TimeoutExpired as error:
    +                raise TimeoutError("external worker hard deadline") from error
    +            return self.process.returncode
    +
    +    def close(self):
    +        retained = []
    +        try:
    +            # Accounting reaching zero can precede final kernel teardown. Retain
    +            # member handles for bounded waits; termination still targets the job.
    +            members = ctypes.create_string_buffer(65536)
    +            if not self.k.QueryInformationJobObject(self.handle, 3, members, len(members), None):
    +                raise ctypes.WinError(ctypes.get_last_error())
    +            count = ctypes.c_uint32.from_buffer(members, 4).value
    +            for index in range(count):
    +                pid = ctypes.c_size_t.from_buffer(members, 8 + index * ctypes.sizeof(ctypes.c_size_t)).value
    +                handle = self.k.OpenProcess(0x00100000, False, pid)
    +                if handle:
    +                    retained.append(handle)
    +            if not self.k.TerminateJobObject(self.handle, 125):
    +                raise ctypes.WinError(ctypes.get_last_error())
    +            deadline = time.monotonic() + 5
    +            for handle in retained:
    +                remaining = max(0, int((deadline - time.monotonic()) * 1000))
    +                if self.k.WaitForSingleObject(handle, remaining) != 0:
    +                    raise TimeoutError("owned member handle did not terminate")
    +            while True:
    +                info = self.accounting()
    +                if not self.k.QueryInformationJobObject(self.handle, 1, ctypes.byref(info), ctypes.sizeof(info), None):
    +                    raise ctypes.WinError(ctypes.get_last_error())
    +                if info.active == 0:
    +                    break
    +                if time.monotonic() >= deadline:
    +                    raise TimeoutError(f"job cleanup survivors: {info.active}")
    +                # Native job completion has no Python event; bounded observable polling.
    +                time.sleep(0.01)
    +            if self.process:
    +                self.process.wait(timeout=2)
    +            return {"activeProcesses": info.active, "totalProcesses": info.total, "jobTerminated": True}
    +        finally:
    +            for handle in retained:
    +                self.k.CloseHandle(handle)
    +            self.k.CloseHandle(self.handle)
    +
    +
    +def validate_input_paths(inputs):
    +    if (set(inputs) != set(INPUT_KEYS)
    +            or any(not isinstance(inputs[key], str) or not Path(inputs[key]).is_absolute() for key in INPUT_KEYS)):
    +        raise ValueError("provide exactly the explicit absolute input paths: " + ", ".join(INPUT_KEYS))
    +
    +
    +def validate_output(inputs, input_file, output):
    +    """Resolve existing junction parents before any output or fixture writes."""
    +    validate_input_paths(inputs)
    +    destination = output.resolve()
    +    directories = {Path(inputs[key]).resolve() for key in ("provider", "sdk", "svnRoot")}
    +    protected = {Path(value).resolve() for value in inputs.values()} | {input_file.resolve()}
    +    for source in protected:
    +        if (destination == source or destination in source.parents
    +                or (source in directories and source in destination.parents)):
    +            raise ValueError(f"output overlaps fixed input: {source}")
    +    return destination
    +
    +
    +def cleanup_proven(row):
    +    cleanup = row.get("cleanup", {})
    +    return ("cleanupError" not in row and cleanup.get("jobTerminated") is True
    +            and cleanup.get("activeProcesses") == 0)
    +
    +
    +def verify_inputs(inputs, runtime, receipt):
    +    validate_input_paths(inputs)
    +    for key, expected in (("sourceIdentity", SOURCE_HASH), ("svnZip", ZIP_HASH),
    +                          ("gate", GATE_HASH), ("gateSource", GATE_SOURCE_HASH)):
    +        if digest(inputs[key]) != expected:
    +            raise ValueError(f"fixed {key} identity mismatch")
    +    source = json.loads(Path(inputs["sourceIdentity"]).read_text())
    +    if source["commit"] != PROVIDER or source["sdk"]["revision"] != SDK:
    +        raise ValueError("fixed Provider/SDK revision mismatch")
    +    identities = []
    +    for root, rows, field in ((inputs["provider"], source["files"], "path"),
    +                              (inputs["sdk"], source["lockedSdkFiles"], "upstream")):
    +        for row in rows:
    +            file = Path(root) / row[field]
    +            if digest(file) != row["sha256"]:
    +                raise ValueError(f"source bytes changed: {file}")
    +            identities.append({"path": str(file), "sha256": row["sha256"], "bytes": file.stat().st_size})
    +    svn_root = Path(inputs["svnRoot"])
    +    with zipfile.ZipFile(inputs["svnZip"]) as archive:
    +        for member in archive.infolist():
    +            if member.is_dir():
    +                continue
    +            relative = Path(member.filename)
    +            if relative.is_absolute() or ".." in relative.parts:
    +                raise ValueError("unsafe ZIP path")
    +            file = svn_root / relative
    +            expected = hashlib.sha256(archive.read(member)).hexdigest()
    +            if digest(file) != expected:
    +                raise ValueError(f"SVN extraction differs from fixed ZIP: {file}")
    +            identities.append({"path": str(file), "sha256": expected, "bytes": file.stat().st_size})
    +    tools = []
    +    commands = []
    +
    +    def checked(command, env=None):
    +        started = time.monotonic()
    +        row = {"command": command, "timeoutSeconds": 10}
    +        try:
    +            result = subprocess.run(command, env=env, timeout=10, creationflags=0x08000000,
    +                                    stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    +            row.update(exitCode=result.returncode, stdout=result.stdout.decode("utf-8-sig", errors="replace"),
    +                       stderr=result.stderr.decode("utf-8-sig", errors="replace"))
    +            result.check_returncode()
    +            return result.stdout
    +        except Exception as error:
    +            row["error"] = str(error)
    +            raise
    +        finally:
    +            row["durationMs"] = (time.monotonic() - started) * 1000
    +            commands.append(row)
    +            write_json(Path(receipt).with_name("identity-commands.json"), commands)
    +
    +    for name in ("svn.exe", "svnadmin.exe"):
    +        file = svn_root / "bin" / name
    +        data = file.read_bytes()
    +        offset = int.from_bytes(data[60:64], "little")
    +        if data[:2] != b"MZ" or data[offset:offset+6] != b"PE\0\0\x64\x86":
    +            raise ValueError("SVN is not PE x64")
    +        env = {**os.environ, "LITHE_DIAGNOSTIC_SIGNED_FILE": str(file)}
    +        # A pwsh caller's module path can prevent Windows PowerShell from loading
    +        # its own signing module. Let this child initialize its native defaults.
    +        env.pop("PSModulePath", None)
    +        signature = checked([inputs["powershell"], "-NoProfile", "-Command",
    +            "$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new(); "
    +            "$s=Get-AuthenticodeSignature -LiteralPath $env:LITHE_DIAGNOSTIC_SIGNED_FILE; "
    +            "@{status=[string]$s.Status;subject=$s.SignerCertificate.Subject;thumbprint=$s.SignerCertificate.Thumbprint} | ConvertTo-Json -Compress"],
    +            env=env).decode("utf-8-sig")
    +        signature = json.loads(signature)
    +        if signature["status"] != "Valid" or signature["thumbprint"] != "6F0D0D78D3D3285EC16DB943B59E806B8B26D9B1":
    +            raise ValueError("SVN publisher signature mismatch")
    +        version = checked([str(file), "--version"]).decode()
    +        if "version 1.15.0 (r1937767)" not in version:
    +            raise ValueError("SVN version mismatch")
    +        tools.append({"path": str(file), "signature": signature, "version": version, "machine": "8664"})
    +    runtime = Path(runtime)
    +    runtime.mkdir()
    +    for name in ("svn-provider.mjs", "svn-output.mjs", "xml.mjs"):
    +        shutil.copyfile(Path(inputs["provider"]) / "io.github.lithe-idea.svn-support/provider" / name, runtime / name)
    +    vendor = runtime / "vendor/lithe-plugin-sdk"
    +    vendor.mkdir(parents=True)
    +    for row in source["lockedSdkFiles"]:
    +        if row["path"].startswith("vendor/lithe-plugin-sdk/src/") and row["path"].endswith(".mjs"):
    +            shutil.copyfile(Path(inputs["provider"]) / row["path"], vendor / Path(row["path"]).name)
    +    runtime_files = [{"path": str(file), "sha256": digest(file)} for file in sorted(runtime.rglob("*")) if file.is_file()]
    +    write_json(receipt, {"verified": True, "providerRepository": "https://github.com/Lithe-IDEA/lithe-plugin-svn",
    +                         "provider": PROVIDER, "sdk": SDK, "sourceManifest": source, "files": identities,
    +                         "runtimeFiles": runtime_files,
    +                         "svnSource": "https://www.visualsvn.com/files/Apache-Subversion-1.15.0-x64.zip",
    +                         "zipSha256": ZIP_HASH, "tools": tools, "gateSha256": GATE_HASH,
    +                         "gateSourceSha256": GATE_SOURCE_HASH, "formalWindowsPackage": False})
    +
    +
    +def reports(output, rows):
    +    write_json(output / "timing.json", {"runner": "SVN move explicit diagnostic", "tests": rows})
    +    suite = ET.Element("testsuite", name="vcs-move-diagnostic", tests=str(len(rows)),
    +                       failures=str(sum(row["status"] != "passed" for row in rows)))
    +    for row in rows:
    +        case = ET.SubElement(suite, "testcase", name=row["name"], time=str(row["durationMs"] / 1000))
    +        if row["status"] != "passed":
    +            ET.SubElement(case, "failure").text = json.dumps(row)
    +    ET.ElementTree(suite).write(output / "timing.junit.xml", encoding="utf-8", xml_declaration=True)
    +    (output / "timing.html").write_text("SVN move diagnostic"
    +        "

    Diagnostic execution; counterexamples are not safety passes

    " +
    +        html.escape(json.dumps(rows, indent=2)) + "
    ", encoding="utf-8") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--inputs", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--case-seconds", type=float, default=45) + parser.add_argument("--suite-seconds", type=float, default=180) + args = parser.parse_args() + if os.name != "nt" or not 1 <= args.case_seconds <= 120 or not 1 <= args.suite_seconds <= 600: + parser.error("Windows required; case 1..120 seconds and suite 1..600 seconds") + inputs = json.loads(args.inputs.read_text(encoding="utf-8-sig")) + args.output = validate_output(inputs, args.inputs, args.output) + args.output.mkdir(parents=True, exist_ok=False) + rows = [] + deadline = time.monotonic() + args.suite_seconds + temporary = Path(tempfile.mkdtemp(prefix="vcs-move-")) + cleanup = {"temporary": str(temporary), "removed": False} + all_jobs_closed = True + try: + runtime = temporary / "runtime" + steps = [("identity", [sys.executable, str(Path(__file__).resolve()), "--verify", str(args.inputs.resolve()), + str(runtime), str(args.output.resolve() / "identity.json")])] + worker = Path(__file__).with_name("vcs-move-diagnostic-worker.mjs") + for name in CASES: + steps.append((name, [inputs["node"], str(worker), str(args.inputs.resolve()), name, + str(temporary / name), str(runtime), str(args.output.resolve() / (name + ".json"))])) + for name, command in steps: + try: + seconds = budget(deadline, args.case_seconds) + except TimeoutError as error: + rows.append({"name": name, "status": "failed", "error": str(error), "durationMs": 0, "exitCode": 124}) + break + row = supervised(command, seconds, args.output / (name + ".log"), WindowsJob) + if not cleanup_proven(row) and not row.get("cleanup", {}).get("notStarted"): + all_jobs_closed = False + row.update(status="failed", cleanupError=row.get("cleanupError", "job termination has no zero-member receipt")) + evidence = args.output / ("identity.json" if name == "identity" else name + ".json") + if row["status"] == "passed": + try: + receipt = json.loads(evidence.read_text()) + if name == "identity" and receipt.get("verified") is not True: + raise AssertionError("identity verification did not complete") + if name != "identity" and (receipt.get("status") != "diagnosis-matched" or receipt.get("safetyPass") is not False): + raise AssertionError("worker did not record a diagnostic-only result") + except Exception as error: + row.update(status="failed", evidenceError=str(error)) + if row["status"] != "passed" and name != "identity": + row.update(operationOutcome="unknown", refreshRequired=True) + # Only a proven zero-member job permits another SVN reader. + # Otherwise retain the fixture so no deletion races a survivor. + if not cleanup_proven(row): + row["afterUnavailable"] = "cleanup-unproven" + else: + try: + recovery_seconds = budget(deadline, 12) + row["recovery"] = supervised([inputs["node"], str(worker), "--snapshot", str(args.inputs.resolve()), + str(temporary / name / "wc"), str(args.output.resolve() / (name + "-recovery.json"))], + recovery_seconds, args.output / (name + "-recovery.log"), WindowsJob) + if not cleanup_proven(row["recovery"]) and not row["recovery"].get("cleanup", {}).get("notStarted"): + all_jobs_closed = False + except TimeoutError as error: + row["afterUnavailable"] = str(error) + rows.append({"name": name, **row}) + reports(args.output, rows) + if row["status"] != "passed": + break + if not all_jobs_closed: + cleanup["inputBytesUnavailable"] = "cleanup-unproven" + elif (args.output / "identity.json").exists(): + started = time.monotonic() + try: + identity = json.loads((args.output / "identity.json").read_text()) + changed = [row["path"] for row in identity["files"] + identity["runtimeFiles"] if digest(row["path"]) != row["sha256"]] + cleanup["inputBytesUnchanged"] = not changed + if changed: + raise AssertionError(f"read-only inputs changed: {changed}") + except Exception as error: + cleanup["inputBytesUnchanged"] = False + rows.append({"name": "input-immutability", "status": "failed", "error": str(error), + "durationMs": (time.monotonic() - started) * 1000, "exitCode": 1}) + except Exception as error: + rows.append({"name": "suite-exception", "status": "failed", "error": str(error), "durationMs": 0, "exitCode": 1}) + raise + finally: + try: + if all_jobs_closed: + cleanup["removed"] = remove_owned_tree(temporary) + else: + cleanup["retained"] = "cleanup-unproven; isolate until exact owned processes are confirmed terminated" + except Exception as error: + cleanup["error"] = str(error) + rows.append({"name": "temporary-cleanup", "status": "failed", "error": str(error), "durationMs": 0, "exitCode": 1}) + raise + finally: + write_json(args.output / "cleanup.json", cleanup) + reports(args.output, rows) + return 0 if len(rows) == 4 and all(row["status"] == "passed" for row in rows) else 1 + + +if __name__ == "__main__": + if sys.argv[1:2] == ["--exec"]: + if sys.stdin.buffer.readline() != b"release\n": + raise SystemExit(125) + # The external job owner supplies the hard timeout for this whole worker. + raise SystemExit(subprocess.call(sys.argv[2:])) + if sys.argv[1:2] == ["--verify"]: + verify_inputs(json.loads(Path(sys.argv[2]).read_text(encoding="utf-8-sig")), sys.argv[3], sys.argv[4]) + else: + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/vcs_move_diagnostic_test.py b/scripts/plugin-host-acceptance/vcs_move_diagnostic_test.py new file mode 100644 index 0000000000..984c917134 --- /dev/null +++ b/scripts/plugin-host-acceptance/vcs_move_diagnostic_test.py @@ -0,0 +1,283 @@ +"""Offline injected process ownership and deadline regression tests.""" +import argparse +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +import os +import sys +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("diagnostic", Path(__file__).with_name("vcs_move_diagnostic.py")) +diagnostic = importlib.util.module_from_spec(spec) +spec.loader.exec_module(diagnostic) + + +def controlled_inputs(root): + return {key: str(root / key) for key in diagnostic.INPUT_KEYS} + + +def closed_job(): + return {"activeProcesses": 0, "jobTerminated": True} + + +class ControlledOwner: + def __init__(self, result=0, error=None, cleanup_error=None): + self.result, self.error, self.cleanup_error = result, error, cleanup_error + self.events = [] + + def run(self, command, seconds, log): + self.events.extend(["assigned-before-release", "root", "descendant"]) + if self.error: + raise self.error + return self.result + + def close(self): + self.events.extend(["terminate-job", "verify-zero-descendants"]) + if self.cleanup_error: + raise self.cleanup_error + return {"activeProcesses": 0} + + +class OwnershipTests(unittest.TestCase): + def run_owner(self, owner): + ticks = iter([1.0, 1.125]) + return diagnostic.supervised(["controlled-executor"], 5, "unused.log", lambda: owner, lambda: next(ticks)) + + def test_exception_and_assertion_failure_clean_entire_owned_tree(self): + for error in (RuntimeError("executor exception"), AssertionError("endpoint assertion")): + with self.subTest(error=str(error)): + owner = ControlledOwner(error=error) + row = self.run_owner(owner) + self.assertEqual(row["status"], "failed") + self.assertEqual(row["durationMs"], 125) + self.assertEqual(owner.events[-2:], ["terminate-job", "verify-zero-descendants"]) + + def test_case_watchdog_and_nonzero_exit_are_failures(self): + for owner in (ControlledOwner(error=TimeoutError("case hard deadline")), ControlledOwner(result=1)): + row = self.run_owner(owner) + self.assertEqual(row["status"], "failed") + self.assertEqual(row["cleanup"]["activeProcesses"], 0) + + def test_cleanup_failure_cannot_be_counted_as_pass(self): + row = self.run_owner(ControlledOwner(cleanup_error=TimeoutError("survivor"))) + self.assertEqual(row["status"], "failed") + self.assertIn("survivor", row["cleanupError"]) + + def test_owner_creation_exception_is_reported_without_losing_outer_cleanup(self): + def unavailable(): + raise RuntimeError("job unavailable") + row = diagnostic.supervised(["controlled"], 1, "unused", unavailable) + self.assertEqual(row["status"], "failed") + self.assertEqual(row["cleanup"], {"notStarted": True}) + + def test_readonly_svn_fixture_cleanup_stays_inside_owned_root(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "owned" + root.mkdir() + revision = root / "revision" + revision.write_bytes(b"fixed revision") + os.chmod(revision, 0o444) + self.assertTrue(diagnostic.remove_owned_tree(root)) + self.assertTrue(Path(directory).exists()) + + def test_suite_budget_caps_each_case_and_stops_before_next_spawn(self): + self.assertEqual(diagnostic.budget(100, 45, lambda: 90), 10) + self.assertEqual(diagnostic.budget(100, 45, lambda: 0), 45) + with self.assertRaises(TimeoutError): + diagnostic.budget(100, 45, lambda: 100) + + def test_input_gate_rejects_missing_or_relative_inputs_before_execution(self): + with self.assertRaises(ValueError): + diagnostic.verify_inputs({}, "unused", "unused") + with self.assertRaises(ValueError): + diagnostic.verify_inputs(dict.fromkeys(("node", "provider", "sdk", "sourceIdentity", "svnZip", "svnRoot", "gate", "gateSource"), "relative"), "unused", "unused") + + def test_outer_suite_finally_removes_temporary_state_after_executor_exception(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + inputs = root / "inputs.json" + inputs.write_text(json.dumps(controlled_inputs(root))) + owned = root / "owned" + owned.mkdir() + output = root / "evidence" + argv = ["diagnostic", "--inputs", str(inputs), "--output", str(output)] + with patch.object(sys, "argv", argv), patch.object(diagnostic.os, "name", "nt"), \ + patch.object(diagnostic.tempfile, "mkdtemp", return_value=str(owned)), \ + patch.object(diagnostic, "supervised", side_effect=AssertionError("injected assertion")): + with self.assertRaises(AssertionError): + diagnostic.main() + self.assertFalse(owned.exists()) + self.assertTrue((output / "cleanup.json").exists()) + self.assertTrue((output / "timing.junit.xml").exists()) + + def test_case_timeout_records_unknown_refresh_and_post_cleanup_recovery(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + inputs, output = root / "inputs.json", root / "evidence" + inputs.write_text(json.dumps(controlled_inputs(root))) + events = [] + + def executor(command, seconds, log, owner): + if "--verify" in command: + diagnostic.write_json(output / "identity.json", {"verified": True, "files": [], "runtimeFiles": []}) + return {"status": "passed", "exitCode": 0, "durationMs": 1, "cleanup": closed_job()} + if "--snapshot" in command: + events.append("recover-only-after-owned-tree-cleanup") + return {"status": "passed", "exitCode": 0, "durationMs": 1, "cleanup": closed_job()} + events.append("timeout-and-owned-tree-cleanup") + return {"status": "failed", "exitCode": 124, "durationMs": 1, "cleanup": closed_job()} + + with patch.object(sys, "argv", ["diagnostic", "--inputs", str(inputs), "--output", str(output)]), \ + patch.object(diagnostic.os, "name", "nt"), patch.object(diagnostic, "supervised", side_effect=executor): + self.assertEqual(diagnostic.main(), 1) + rows = __import__('json').loads((output / "timing.json").read_text())["tests"] + self.assertEqual(rows[1]["operationOutcome"], "unknown") + self.assertTrue(rows[1]["refreshRequired"]) + self.assertEqual(events, ["timeout-and-owned-tree-cleanup", "recover-only-after-owned-tree-cleanup"]) + self.assertTrue(__import__('json').loads((output / "cleanup.json").read_text())["removed"]) + + def test_cleanup_unproven_skips_recovery_and_retains_owned_working_copy(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + inputs, output, owned = root / "inputs.json", root / "evidence", root / "owned" + inputs.write_text(json.dumps(controlled_inputs(root))) + owned.mkdir() + commands = [] + + def executor(command, seconds, log, owner): + commands.append(command) + if "--verify" in command: + diagnostic.write_json(output / "identity.json", {"verified": True, "files": [], "runtimeFiles": []}) + return {"status": "passed", "exitCode": 0, "durationMs": 1, "cleanup": closed_job()} + return {"status": "failed", "exitCode": 124, "durationMs": 1, + "cleanupError": "job cleanup survivors: 1", "cleanup": {"activeProcesses": 1}} + + with patch.object(sys, "argv", ["diagnostic", "--inputs", str(inputs), "--output", str(output)]), \ + patch.object(diagnostic.os, "name", "nt"), \ + patch.object(diagnostic.tempfile, "mkdtemp", return_value=str(owned)), \ + patch.object(diagnostic, "supervised", side_effect=executor), \ + patch.object(diagnostic, "remove_owned_tree") as remove: + self.assertEqual(diagnostic.main(), 1) + remove.assert_not_called() + self.assertFalse(any("--snapshot" in command for command in commands)) + report = json.loads((output / "timing.json").read_text()) + self.assertEqual(report["tests"][1]["afterUnavailable"], "cleanup-unproven") + cleanup = json.loads((output / "cleanup.json").read_text()) + self.assertFalse(cleanup["removed"]) + self.assertIn("cleanup-unproven", cleanup["retained"]) + self.assertTrue(owned.exists()) + + def test_output_overlap_is_rejected_before_any_write(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + inputs = root / "inputs.json" + values = controlled_inputs(root) + inputs.write_text(json.dumps(values)) + for key in ("provider", "sdk", "svnRoot"): + fixed = Path(values[key]) + fixed.mkdir() + output = fixed / "new-evidence" + with self.subTest(key=key), \ + patch.object(sys, "argv", ["diagnostic", "--inputs", str(inputs), "--output", str(output)]), \ + patch.object(diagnostic.os, "name", "nt"), patch.object(diagnostic, "supervised") as execute: + with self.assertRaisesRegex(ValueError, "overlaps fixed input"): + diagnostic.main() + execute.assert_not_called() + self.assertEqual(list(fixed.iterdir()), []) + + @unittest.skipUnless(os.name == "nt", "Windows junction identity") + def test_output_junction_alias_cannot_write_inside_fixed_input(self): + import _winapi + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + values = controlled_inputs(root) + source, alias = Path(values["provider"]), root / "alias" + source.mkdir() + _winapi.CreateJunction(str(source), str(alias)) + try: + with self.assertRaisesRegex(ValueError, "overlaps fixed input"): + diagnostic.validate_output(values, root / "inputs.json", alias / "new-evidence") + self.assertEqual(list(source.iterdir()), []) + finally: + alias.rmdir() + + def test_final_hash_mismatch_and_read_error_are_failed_in_all_reports(self): + for failure in (None, OSError("input read failed")): + with self.subTest(readError=failure is not None), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + inputs, output = root / "inputs.json", root / "evidence" + inputs.write_text(json.dumps(controlled_inputs(root))) + + def executor(command, seconds, log, owner): + if "--verify" in command: + receipt = {"verified": True, "files": [{"path": str(root / "fixed"), "sha256": "expected"}], "runtimeFiles": []} + else: + receipt = {"status": "diagnosis-matched", "safetyPass": False} + diagnostic.write_json(output / ("identity.json" if "--verify" in command else log.stem + ".json"), receipt) + return {"status": "passed", "exitCode": 0, "durationMs": 1, "cleanup": closed_job()} + + with patch.object(sys, "argv", ["diagnostic", "--inputs", str(inputs), "--output", str(output)]), \ + patch.object(diagnostic.os, "name", "nt"), patch.object(diagnostic, "supervised", side_effect=executor), \ + patch.object(diagnostic, "digest", side_effect=failure, return_value="changed"): + self.assertEqual(diagnostic.main(), 1) + rows = json.loads((output / "timing.json").read_text())["tests"] + self.assertEqual(rows[-1]["name"], "input-immutability") + self.assertEqual(rows[-1]["status"], "failed") + self.assertFalse(json.loads((output / "cleanup.json").read_text())["inputBytesUnchanged"]) + suite = diagnostic.ET.parse(output / "timing.junit.xml").getroot() + self.assertEqual(suite.get("failures"), "1") + self.assertIsNotNone(suite.find("testcase[@name='input-immutability']/failure")) + self.assertIn("input-immutability", (output / "timing.html").read_text()) + + @unittest.skipUnless(os.name == "nt", "Windows kernel job integration") + def test_native_job_cleans_descendant_after_root_exit(self): + # Child signals readiness over a pipe; no sleep-based synchronization. + # Parent exits first. The retained job still owns the waiting descendant. + child = "import threading; print('ready',flush=True); threading.Event().wait(30)" + root = "import subprocess,sys; p=subprocess.Popen([sys.executable,'-c'," + repr(child) + "],stdout=subprocess.PIPE); assert p.stdout.readline().strip()==b'ready'" + directory = tempfile.mkdtemp() + try: + row = diagnostic.supervised([sys.executable, "-c", root], 5, Path(directory) / "native.log", diagnostic.WindowsJob) + self.assertEqual(row["status"], "passed", row) + self.assertEqual(row["cleanup"]["activeProcesses"], 0) + self.assertGreaterEqual(row["cleanup"]["totalProcesses"], 3) + finally: + diagnostic.remove_owned_tree(Path(directory)) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--report", type=Path) + parser.add_argument("--case") + args = parser.parse_args() + if args.case: + result = unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromName(args.case, sys.modules[__name__])) + print("LITHE_TEST_RESULT=" + json.dumps({"skipped": bool(result.skipped)}), flush=True) + return 0 if result.wasSuccessful() else 1 + if args.report is None: + parser.error("--report is required") + args.report.parent.mkdir(parents=True, exist_ok=True) + rows = [] + for name in unittest.defaultTestLoader.getTestCaseNames(OwnershipTests): + log = args.report.with_name(args.report.stem + "-" + name + ".log") + row = diagnostic.supervised([sys.executable, "-B", str(Path(__file__).resolve()), "--case", "OwnershipTests." + name], + 10, log, diagnostic.WindowsJob) + row.update(name=name, suite="plugin-host-acceptance / vcs-move-diagnostic-helpers", maxMs=10000) + if row["status"] == "passed": + outcomes = [line for line in log.read_text(encoding="utf-8").splitlines() if line.startswith("LITHE_TEST_RESULT=")] + if len(outcomes) != 1: + row.update(status="failed", error="missing child unittest result") + elif json.loads(outcomes[0].split("=", 1)[1])["skipped"]: + row["status"] = "skipped" + if row["durationMs"] >= row["maxMs"]: + row.update(status="timeout", error="hard case budget exceeded, including job cleanup") + rows.append(row) + print(f"{row['status'].upper()} {name} ({row['durationMs']:.0f} ms)", flush=True) + args.report.write_text(json.dumps({"runner": "acceptance", "warnMs": 2000, "tests": rows}, indent=2) + "\n", encoding="utf-8") + return 1 if any(row["status"] not in ("passed", "skipped") for row in rows) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/web_bridge_fallback_acceptance.py b/scripts/plugin-host-acceptance/web_bridge_fallback_acceptance.py new file mode 100644 index 0000000000..d632aea383 --- /dev/null +++ b/scripts/plugin-host-acceptance/web_bridge_fallback_acceptance.py @@ -0,0 +1,488 @@ +#!/usr/bin/env python3 +"""Real ui:2 fetch failure -> pinned Tauri postMessage fallback, through CDP only. + +No transport/callback replacement, native reply fabrication or capability change. +This controlled unsigned x64 subset never establishes complete M10 acceptance. +""" +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +import os +import platform +import re +import struct +import sys +from pathlib import Path +from urllib.parse import urlsplit + +from build_fixture import ROOT, confined, json_bytes +from settings_functions_acceptance import assert_resources, resource_diff, snapshot_resources, verify_host, wait_for +from web_bridge_fallback_fixture import DISPLAY_NAME, FILES, PLUGIN_ID, declaration, load_plan, sha256, source_inventory + +IPC_URL = "http://ipc.localhost/plugin%3Adeclarative-web%7Crequest" +WARNING = "IPC custom protocol failed, Tauri will now use the postMessage interface instead" +ORIGIN = "http://lithe-plugin." + PLUGIN_ID +CASES = ("identity-and-install-review", "production-private-page", "first-fetch-failure-real-fallback", + "second-state-sticky-path", "transport-and-callback-invariants", "close-and-uninstall", + "fetch-session-cleanup", "product-and-profile-cleanup", "resource-invariance") + +# Read descriptors, function sources and the real debugging Map. Never assign a +# transport, touch Map entries, invoke callbacks, or retain page globals. +SNAPSHOT = r"""() => { + const i=window.__TAURI_INTERNALS__; + const describe=(owner,key)=>{const d=Object.getOwnPropertyDescriptor(owner,key); + return d?{writable:d.writable??null,configurable:d.configurable,enumerable:d.enumerable, + frozen:Object.isFrozen(d.value),type:typeof d.value, + source:typeof d.value==='function'?Function.prototype.toString.call(d.value):null, + get:d.get?Function.prototype.toString.call(d.get):null}:null;}; + return {url:location.href,pattern:i.__TAURI_PATTERN__.pattern, + callbacks:{isMap:i.callbacks instanceof Map,size:i.callbacks.size,ids:[...i.callbacks.keys()]}, + descriptors:{lithe:describe(window,'lithe'),request:describe(window.lithe,'request'), + invoke:describe(i,'invoke'),ipc:describe(i,'ipc'),postMessage:describe(i,'postMessage'), + nativePostMessage:describe(window.ipc,'postMessage'),fetch:describe(window,'fetch'), + callbacks:describe(i,'callbacks')},bridgeFrozen:Object.isFrozen(window.lithe)}; +}""" + + +def cdp_send(session, method, params=None): + # Public sync send has no timeout. Bound the same pinned Playwright bridge. + return session._sync(asyncio.wait_for(session._impl_obj.send(method, params or {}), timeout=10)) + + +def classify_request(event: dict, frame_id: str, nonce: str, already_failed: bool) -> tuple[str, str | None]: + request = event["request"] + url = request["url"] + if event.get("frameId") != frame_id: + return "continue", "paused request belongs to an unexpected frame" + if url == IPC_URL: + headers = {key.lower(): value for key, value in request.get("headers", {}).items()} + # The pinned native IPC protocol separately handles OPTIONS without + # invoking a command. If Chromium exposes a CORS preflight, release + # only that exact origin/method/header set; it is not a second fetch. + if request.get("method") == "OPTIONS": + requested_headers = {part.strip().lower() for part in headers.get("access-control-request-headers", "").split(",")} + if (not already_failed and headers.get("origin") == ORIGIN + and headers.get("access-control-request-method") == "POST" + and requested_headers == {"content-type", "tauri-callback", "tauri-error", "tauri-invoke-key"} + and not request.get("postData")): + return "continue", None + return "continue", "unexpected IPC preflight; refusing generalized interception" + try: + body = json.loads(request.get("postData", "")) + exact = (request.get("method") == "POST" and body.get("method") == "ui.setState" + and body.get("params") == {"state": {"nonce": nonce}} + and type(body.get("generation")) is int and body["generation"] > 0 + and set(body) == {"method", "params", "generation"} + and all(str(headers.get(key, "")).isdigit() for key in ("tauri-callback", "tauri-error")) + and bool(headers.get("tauri-invoke-key"))) + except (ValueError, TypeError, AttributeError): + exact = False + if not exact or already_failed: + return "continue", "unexpected/duplicate IPC request; refusing generalized fault injection" + return "fail", None + if urlsplit(url).hostname == "ipc.localhost" or url.startswith("ipc:"): + return "continue", "extra IPC command observed" + if url.startswith(ORIGIN + "/"): + return "continue", None + return "continue", "unexpected external request observed" + + +class FetchFault: + """One owner for the plugin-target Fetch domain and every paused request.""" + def __init__(self, session, frame_id, nonce): + self.session, self.frame_id, self.nonce = session, frame_id, nonce + self.paused, self.records, self.errors = set(), [], [] + self.failed = 0 + self.enabled = False + + def on_paused(self, event): + request_id = event["requestId"] + self.paused.add(request_id) + request = event["request"] + row = {"requestId": request_id, "networkId": event.get("networkId"), "frameId": event.get("frameId"), + "url": request["url"], "method": request["method"], "postData": request.get("postData"), + "headers": {k: v for k, v in request.get("headers", {}).items() + if k.lower() in ("content-type", "tauri-callback", "tauri-error", "origin", + "access-control-request-method", "access-control-request-headers")}} + self.records.append(row) + try: + decision, error = classify_request(event, self.frame_id, self.nonce, self.failed != 0) + if error: + self.errors.append(error) + row.update(decision=decision, classificationError=error) + if decision == "fail": + cdp_send(self.session, "Fetch.failRequest", {"requestId": request_id, "errorReason": "Failed"}) + self.failed += 1 + else: + cdp_send(self.session, "Fetch.continueRequest", {"requestId": request_id}) + row["released"] = True + self.paused.discard(request_id) + except Exception as error: + row["releaseError"] = str(error) + self.errors.append(str(error)) + + def start(self): + self.session.on("Fetch.requestPaused", self.on_paused) + # Observe all requests on this private target only; fail exactly one + # confirmed IPC POST. All others are released and unexpected ones fail + # acceptance. No other WebView2 session receives Fetch.enable. + self.enabled = True + cdp_send(self.session, "Fetch.enable", {"patterns": [{"urlPattern": "*", "requestStage": "Request"}]}) + + def close(self): + errors = [] + try: + for request_id in list(self.paused): + try: + cdp_send(self.session, "Fetch.continueRequest", {"requestId": request_id}) + self.paused.discard(request_id) + self.records.append({"requestId": request_id, "decision": "cleanup-continue", "released": True}) + except Exception as error: + errors.append(str(error)) + finally: + try: + if self.enabled: + cdp_send(self.session, "Fetch.disable") + self.enabled = False + # Disabling Fetch releases any requests that raced cleanup. + self.paused.clear() + except Exception as error: + errors.append(str(error)) + finally: + self.session.remove_listener("Fetch.requestPaused", self.on_paused) + self.session._sync(asyncio.wait_for(self.session._impl_obj.detach(), timeout=10)) + if errors: + raise RuntimeError("Fetch cleanup: " + "; ".join(errors)) + + def assert_one(self): + ipc = [row for row in self.records if urlsplit(row.get("url", "")).hostname == "ipc.localhost" and row.get("method") == "POST"] + if self.errors or self.failed != 1 or len(ipc) != 1 or self.paused: + raise AssertionError(f"fault was not exactly one released IPC fetch: {self.errors}; {self.failed}/{len(ipc)}; paused={self.paused}") + + +def snapshot(page) -> dict: + result = page.evaluate(SNAPSHOT) + for descriptor in result["descriptors"].values(): + if descriptor and descriptor.get("source") is not None: + descriptor["sourceSha256"] = hashlib.sha256(descriptor["source"].encode()).hexdigest() + return result + + +def require_x64_executable(path: Path) -> str: + with path.open("rb") as stream: + header = stream.read(64) + if len(header) != 64 or header[:2] != b"MZ": + raise ValueError("acceptance executable is not PE") + offset = struct.unpack_from(" { + let timer; + try {return await Promise.race([window.lithe.request(method,params), + new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('ui2 request deadline')),10000);})]);} + finally {clearTimeout(timer);} + }""", [method, params]) + + +def run(args, driver, reporter): + plan = load_plan(args.fixture_root) + evidence = args.artifact_root + evidence.mkdir(parents=True, exist_ok=False) + baseline = snapshot_resources(args.run_image) + archive_before = sha256(confined(args.fixture_root, plan["archive"]["path"])) + driver.write_json(evidence / "resources-before.json", baseline) + session = page = plugin = fault = cdp = None + first_failure = None + package_root = args.app_local_data / "plugins" / PLUGIN_ID / "1.0.0" + package_before = None + shutdown_completed = False + console = [] + def step(name): + return reporter.step("controlled-ui2-fallback / " + name, max_ms=420_000 if name == CASES[0] else 120_000) + def wait(predicate, description): + return wait_for(predicate, description, pump=lambda: page.evaluate("() => true"), seconds=15) + def catalog(): + return next((row for row in driver.invoke(page, "declarative_plugins_state") + if row["pluginId"] == PLUGIN_ID and row["source"] == "installed"), None) + def details(): + driver.open_plugin_settings(page) + row = page.locator(f"[data-plugin-id='{PLUGIN_ID}'][data-plugin-source='installed']") + row.get_by_role("button").click(timeout=10_000) + region = page.locator("[data-settings-content='declarative-plugins']").get_by_role("region", name=DISPLAY_NAME, exact=True) + region.wait_for(state="visible", timeout=15_000) + return region + try: + with driver.sync_playwright() as playwright: + try: + with step(CASES[0]): + identity = verify_host(args.host_identity, args.executable, plan) + config = args.host_identity.with_name("tauri.host-acceptance.conf.json") + if (identity.get("configSha256") != sha256(config) or identity.get("sourceInventory") != source_inventory() + or platform.machine().lower() not in ("amd64", "x86_64")): + raise ValueError("full source/config/x64 host identity mismatch") + driver.write_json(evidence / "identity.json", {"host": identity, "fixture": plan, + "osArchitecture": platform.machine(), "executableSha256": sha256(args.executable), + "peMachine": require_x64_executable(args.executable), + "acceptanceSources": {name: sha256(Path(__file__).with_name(name)) for name in + ("web_bridge_fallback_fixture.py", "web_bridge_fallback_acceptance.py", "web_bridge_fallback_acceptance_test.py")}}) + session = driver.AppSession(playwright, args.executable, args.fixture_root / "workspace", args.webview_data, evidence / "app", 1) + reporter.capture_failure = lambda: driver.capture_session_failure(session, evidence) + page = session.start() + page.set_default_timeout(10_000) + driver.finish_onboarding(page) + # Clean profiles use the product's Chinese default. Change + # only this owned profile through the real settings control + # before reusing the existing English import-review helper. + settings_button = page.get_by_role("button", name=re.compile(r"^(Settings|设置)$")).first + settings_button.wait_for(state="visible", timeout=15_000) + if settings_button.get_attribute("aria-label") == "设置": + settings_button.click(timeout=15_000) + page.locator("[data-settings-category='general']").click(timeout=15_000) + general = page.locator("[data-settings-content='general']") + general.locator("[data-setting-primary-control]").filter(has_text="简体中文").click(timeout=15_000) + page.get_by_role("option", name="English", exact=True).click(timeout=15_000) + general.locator("[data-setting-primary-control]").filter(has_text="English").wait_for(state="visible", timeout=15_000) + page.get_by_role("button", name="Close", exact=True).last.click(timeout=15_000) + general.wait_for(state="hidden", timeout=15_000) + driver.finish_onboarding(page) + driver.write_json(evidence / "test-locale.json", {"language": "en-US", "method": "owned profile production settings UI"}) + review = driver.import_review(page, session.process.pid, confined(args.fixture_root, plan["archive"]["path"])) + text = review.inner_text(timeout=5000) + driver.write_json(evidence / "install-review.json", {"text": text, "archive": plan["archive"]}) + if any(value not in text for value in ("Unverified publisher", "ui.contribute", "ui.web")): + raise AssertionError("complete unsigned permissions/trust were not disclosed") + # The production UI omits ProgramDeclarationList when the + # native review's programs array is empty (not a disabled + # "(0)" button). The installation receipt independently + # binds that empty approval to the exact ZIP digest. + if review.get_by_role("button", name=re.compile(r"^Program declaration \(")).count(): + raise AssertionError("static fixture unexpectedly declares programs") + disclosed = review.locator("li").all_text_contents() + if sorted(disclosed) != ["ui.contribute", "ui.web"]: + raise AssertionError("review permissions differ from the complete package") + driver.confirm_review(page, review) + wait(lambda: (row if (row := catalog()) and row["status"] == "accepted" and row["enabled"] else None), "accepted ui2 package") + record = json.loads((package_root.parent / "installation.json").read_text(encoding="utf-8")) + if record["disk"]["archiveSha256"] != plan["archive"]["sha256"] or record["disk"]["approvedPrograms"] != []: + raise AssertionError("installation approval/archive identity mismatch") + driver.write_json(evidence / "installation.json", record) + package_before = snapshot_resources(package_root) + driver.write_json(evidence / "package-before.json", package_before) + expected_package = {"ui/": {"kind": "directory"}} + for name, data in {"plugin.json": json_bytes(declaration()), **FILES}.items(): + expected_package[name] = {"kind": "file", "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()} + assert_resources(expected_package, package_before) + with step(CASES[1]): + window_row = page.get_by_test_id("declarative-web-window-list").locator("li").filter(has_text=DISPLAY_NAME) + window_row.get_by_role("button", name="Open", exact=True).click(timeout=10_000) + def private_page(): + matches = [p for context in session.browser.contexts for p in context.pages if p.url == ORIGIN + "/ui/index.html"] + if len(matches) > 1: + raise AssertionError("ambiguous private plugin page") + return matches[0] if matches else None + plugin = wait(private_page, "production private WebView2") + plugin.set_default_timeout(10_000) + plugin.wait_for_function("() => Boolean(window.lithe?.request)", timeout=15_000) + before = snapshot(plugin) + assert_transport(before, before) + driver.write_json(evidence / "transport-before.json", before) + cdp = plugin.context.new_cdp_session(plugin) + target = cdp_send(cdp, "Target.getTargetInfo")["targetInfo"] + frame_id = cdp_send(cdp, "Page.getFrameTree")["frameTree"]["frame"]["id"] + if target["url"] != ORIGIN + "/ui/index.html" or target["type"] != "page": + raise AssertionError("CDP target is not the owning plugin page") + driver.write_json(evidence / "cdp-target.json", target) + nonce = os.urandom(16).hex() + plugin.on("console", lambda message: console.append({"type": message.type, "text": message.text})) + fault = FetchFault(cdp, frame_id, nonce) + fault.start() + with step(CASES[2]): + first = request(plugin, "ui.setState", {"state": {"nonce": nonce}}) + fault.assert_one() + first_snapshot = snapshot(plugin) + assert_transport(before, first_snapshot) + driver.write_json(evidence / "transport-first-settled.json", first_snapshot) + warnings = [row for row in console if row["type"] == "warning" and row["text"].startswith(WARNING)] + if first != {} or len(warnings) != 1: + raise AssertionError("original Promise/warning did not prove pinned native fallback") + driver.write_json(evidence / "first-fallback.json", {"nonce": nonce, "result": first, "warnings": warnings, + "ipcFetchCount": 1, "injection": "CDP Fetch.failRequest Failed, exactly once on the private target"}) + with step(CASES[3]): + if not fault.enabled: + raise AssertionError("Fetch interception was disabled before sticky request") + second = request(plugin, "ui.getState", None) + fault.assert_one() + if second != {"state": {"nonce": nonce}}: + raise AssertionError("sticky native read did not return the written nonce") + if len([row for row in console if row["text"].startswith(WARNING)]) != 1: + raise AssertionError("unexpected repeated fallback warning") + driver.write_json(evidence / "sticky-state.json", {"result": second, "ipcFetchCount": 1, "fetchStillEnabled": True}) + with step(CASES[4]): + after = snapshot(plugin) + driver.write_json(evidence / "transport-after.json", after) + assert_transport(before, after) + fault.assert_one() + with step(CASES[6]): + fault.close() + cdp = None + driver.write_json(evidence / "fetch-cleanup.json", {"disabled": not fault.enabled, "paused": sorted(fault.paused), "detached": True}) + with step(CASES[5]): + with plugin.expect_event("close", timeout=15_000): + window_row.get_by_role("button", name="Close", exact=True).click(timeout=10_000) + package_after = snapshot_resources(package_root) + driver.write_json(evidence / "package-after.json", package_after) + assert_resources(package_before, package_after) + details().get_by_role("button", name="Uninstall", exact=True).click(timeout=10_000) + wait(lambda: catalog() is None, "uninstall static package") + if package_root.parent.exists() or (args.app_local_data / "plugin-state" / PLUGIN_ID).exists(): + raise AssertionError("uninstall retained package/state") + driver.write_json(evidence / "uninstall.json", {"pageClosed": plugin.is_closed(), "packageAndStateAbsent": True}) + except Exception as error: + first_failure = error + driver.write_json(evidence / "first-failure.json", {"type": type(error).__name__, "message": str(error)}) + finally: + try: + if cdp is not None: + with step("failure-fetch-session-cleanup"): + if fault is not None: + fault.close() + else: + cdp._sync(asyncio.wait_for(cdp._impl_obj.detach(), timeout=10)) + except Exception as error: + first_failure = first_failure or error + finally: + try: + if fault is not None: + driver.write_json(evidence / "paused-requests.json", {"records": fault.records, "errors": fault.errors, + "failedCount": fault.failed, "paused": sorted(fault.paused), "fetchEnabled": fault.enabled}) + driver.write_json(evidence / "console.json", console) + except Exception as error: + # Evidence failure must not skip native/profile cleanup. + first_failure = first_failure or error + try: + with step(CASES[7]): + cleanup_errors = [] + try: + if plugin is not None and not plugin.is_closed(): + final_callbacks = snapshot(plugin) + driver.write_json(evidence / "failure-transport-final.json", final_callbacks) + if package_before is not None and package_root.exists(): + failed_package = snapshot_resources(package_root) + driver.write_json(evidence / "failure-package-final.json", failed_package) + assert_resources(package_before, failed_package) + except Exception as error: + cleanup_errors.append(str(error)) + try: + if session is not None: + driver.write_json(evidence / "shutdown.json", session.stop()) + except Exception as error: + cleanup_errors.append(str(error)) + finally: + # Retained native handles, never a process name, + # authorize last-resort cleanup after quit fails. + for owner in list(driver.owned_processes.values()): + if owner.running(): + try: + driver.terminate_process_tree(owner.pid) + except Exception as error: + cleanup_errors.append(str(error)) + survivors = [owner.pid for owner in driver.owned_processes.values() if owner.running()] + if not survivors: + for path, name in ((args.app_local_data, driver.ACCEPTANCE_IDENTIFIER), + (args.app_roaming_data, driver.ACCEPTANCE_IDENTIFIER), + (args.webview_data, "webview2")): + try: + driver.safe_remove_owned_directory(path, path.parent, name) + except Exception as error: + cleanup_errors.append(str(error)) + profiles_absent = not any(p.exists() for p in (args.app_local_data, args.app_roaming_data, args.webview_data)) + if not profiles_absent: + cleanup_errors.append("isolated profiles remain") + driver.write_json(evidence / "cleanup.json", {"ownedSurvivors": survivors, "errors": cleanup_errors, + "profilesAbsent": profiles_absent}) + if survivors or cleanup_errors: + raise RuntimeError(f"cleanup failures: {cleanup_errors}; survivors: {survivors}") + shutdown_completed = session is not None and session.process is not None + except Exception as error: + first_failure = first_failure or error + except Exception as error: + # Playwright context teardown can fail after a more useful native error. + first_failure = first_failure or error + finally: + try: + with step(CASES[8]): + after = snapshot_resources(args.run_image) + driver.write_json(evidence / "resources-after.json", after) + driver.write_json(evidence / "resources-diff.json", resource_diff(baseline, after)) + assert_resources(baseline, after) + if sha256(confined(args.fixture_root, plan["archive"]["path"])) != archive_before: + raise AssertionError("fixture archive changed") + if package_before is not None and package_root.exists(): + assert_resources(package_before, snapshot_resources(package_root)) + if not shutdown_completed: + raise AssertionError("native workflow/shutdown did not complete before the final resource comparison") + except Exception as error: + first_failure = first_failure or error + for owner in driver.owned_processes.values(): + owner.close() + driver.owned_processes.clear() + if first_failure: + raise first_failure + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for key in ("fixture-root", "host-identity", "executable", "run-image", "artifact-root", "report", "app-local-data", "app-roaming-data", "webview-data"): + parser.add_argument("--" + key, required=True, type=Path) + args = parser.parse_args() + for key, value in vars(args).items(): + setattr(args, key, Path(os.path.abspath(value))) + # Load native Windows bindings only in the explicit product invocation. + import acceptance_driver as driver + for path in (args.app_local_data, args.app_roaming_data, args.webview_data): + if path.exists(): + parser.error("isolated profiles must be absent before this invocation") + if (args.app_local_data != Path(os.environ["LOCALAPPDATA"]) / driver.ACCEPTANCE_IDENTIFIER + or args.app_roaming_data != Path(os.environ["APPDATA"]) / driver.ACCEPTANCE_IDENTIFIER + or args.webview_data.name != "webview2"): + parser.error("unexpected acceptance profile identity") + reporter = driver.Reporter(args.report.with_suffix(".progress.json")) + exit_code = 0 + try: + run(args, driver, reporter) + except Exception as error: + print(f"{type(error).__name__}: {error}", file=sys.stderr) + exit_code = 1 + finally: + completed = {row["name"].split(" / ")[-1] for row in reporter.tests} + for name in CASES: + if name not in completed: + reporter.document_pending("controlled-ui2-fallback / " + name, "Not reached; never inferred from helper checks.") + reporter.document_pending("complete-M10", "partial/pending: physical network faults, ui1/functions/ui3, keyboard/IME, arm64, publisher-signed complete packages and all other M10 remain unverified; gates unchanged.") + driver.write_json(args.report, {"runner": "acceptance", "warnMs": 30_000, "maxMs": 120_000, + "complete": False, "controlledSubsetPassed": exit_code == 0, "tests": reporter.tests}) + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/web_bridge_fallback_acceptance_test.py b/scripts/plugin-host-acceptance/web_bridge_fallback_acceptance_test.py new file mode 100644 index 0000000000..5a50f2edf4 --- /dev/null +++ b/scripts/plugin-host-acceptance/web_bridge_fallback_acceptance_test.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""Bounded helper checks; none is evidence of native IPC fallback.""" +from __future__ import annotations + +import argparse +import contextlib +import copy +import io +import json +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, Mock, patch + +import web_bridge_fallback_acceptance as acceptance +import web_bridge_fallback_fixture as fixture + + +class WebFallbackHelperTests(unittest.TestCase): + @contextlib.contextmanager + def source_repository(self): + import text_window_fixture + + with tempfile.TemporaryDirectory(prefix="lithe-source-inventory-") as temporary: + root = Path(temporary).resolve() + subprocess.run(["git", "init", "--quiet", str(root)], check=True, + capture_output=True, timeout=3) + (root / ".gitignore").write_text(".artifacts/\nnode_modules/\ntarget/\ndist/\n", encoding="utf-8") + helpers = root / "scripts/plugin-host-acceptance" + helpers.mkdir(parents=True) + for name in ("text_window_fixture.py", "text_window_acceptance.py", "text_window_acceptance_test.py"): + (helpers / name).write_bytes(b"controlled helper source") + (root / "package.json").write_bytes(b'{"name":"controlled-inventory"}') + with patch.object(fixture, "ROOT", root), patch.object(text_window_fixture, "ROOT", root), \ + patch.object(text_window_fixture, "__file__", str(helpers / "text_window_fixture.py")): + yield root, text_window_fixture + + def test_source_inventory_untracked_input_changes_match_every_lane(self): + # Only a disposable Git repository changes; no real build inputs or + # build marker are touched. Both host lanes must see the same bytes. + with self.source_repository() as (root, text_fixture): + baseline = fixture.source_inventory() + self.assertIn("package.json", baseline) + self.assertEqual(baseline, text_fixture.source_inventory()) + names = ("windows/tauri/src/new input 中文.ts", "rust/lithe-core/src/new.rs", + "scripts/new-helper.py", "third_party/tool/manifest.json") + for name in names: + source = root / name + source.parent.mkdir(parents=True, exist_ok=True) + source.write_bytes(b"first source bytes") + added = fixture.source_inventory() + self.assertEqual(set(added) - set(baseline), set(names)) + self.assertEqual(list(added), sorted(added)) + self.assertEqual(added, text_fixture.source_inventory()) + for name in names: + (root / name).write_bytes(b"changed source bytes") + changed = fixture.source_inventory() + self.assertEqual(set(changed), set(added)) + for name in names: + with self.subTest(source=name): + self.assertNotEqual(changed[name], added[name]) + self.assertEqual(changed, text_fixture.source_inventory()) + for name in names: + (root / name).unlink() + self.assertEqual(fixture.source_inventory(), baseline) + self.assertEqual(text_fixture.source_inventory(), baseline) + + def test_source_inventory_ignored_outputs_do_not_change_either_lane(self): + with self.source_repository() as (root, text_fixture): + baseline = fixture.source_inventory() + names = ("windows/tauri/node_modules/dependency/index.js", "windows/tauri/dist/bundle.js", + "rust/target/generated.rs", "third_party/tool/target/extracted.bin", + ".artifacts/acceptance/generated.py") + for name in names: + output = root / name + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(b"ignored output") + self.assertEqual(fixture.source_inventory(), baseline) + self.assertEqual(text_fixture.source_inventory(), baseline) + for name in names: + (root / name).write_bytes(b"changed ignored output") + self.assertEqual(fixture.source_inventory(), baseline) + self.assertEqual(text_fixture.source_inventory(), baseline) + + def test_source_inventory_deadline_precedes_git_and_empty_file_hashing(self): + with patch.object(fixture.time, "monotonic", return_value=10), \ + patch.object(fixture.subprocess, "run") as command: + with self.assertRaisesRegex(TimeoutError, "300 seconds"): + fixture.source_inventory(deadline=10) + command.assert_not_called() + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "empty.rs").touch() + with patch.object(fixture, "ROOT", root), \ + patch.object(fixture.subprocess, "run", return_value=SimpleNamespace(stdout=b"empty.rs\0")) as command, \ + patch.object(fixture.time, "monotonic", side_effect=[0, 10]): + with self.assertRaisesRegex(TimeoutError, "300 seconds"): + fixture.source_inventory(deadline=10) + self.assertEqual(command.call_args.kwargs["timeout"], 10) + + def event(self): + return {"requestId": "request-1", "frameId": "plugin-frame", "request": { + "url": acceptance.IPC_URL, "method": "POST", "headers": {"Tauri-Callback": "12", "Tauri-Error": "13", "Tauri-Invoke-Key": "ephemeral"}, + "postData": json.dumps({"method": "ui.setState", "params": {"state": {"nonce": "nonce"}}, "generation": 1})}} + + def test_only_exact_owned_state_post_can_be_failed(self): + event = self.event() + self.assertEqual(acceptance.classify_request(event, "plugin-frame", "nonce", False), ("fail", None)) + changes = [("frameId", "other"), ("method", "GET"), ("url", acceptance.IPC_URL + "?extra=1"), + ("postData", "invalid"), ("postData", json.dumps({"method": "ui.getState", "params": None, "generation": 1})), + ("headers", {}), ("postData", json.dumps({"method": "ui.setState", "params": {"state": {"nonce": "other"}}, "generation": 1}))] + for key, value in changes: + changed = copy.deepcopy(event) + (changed if key == "frameId" else changed["request"])[key] = value + decision, error = acceptance.classify_request(changed, "plugin-frame", "nonce", False) + self.assertEqual(decision, "continue") + self.assertIsNotNone(error) + self.assertEqual(acceptance.classify_request(event, "plugin-frame", "nonce", True)[0], "continue") + + def test_local_static_is_released_but_other_ipc_or_network_fails_acceptance(self): + event = self.event() + for url, rejected in ((acceptance.ORIGIN + "/ui/index.html", False), + ("http://ipc.localhost/plugin%3Aother%7Crequest", True), + (acceptance.ORIGIN + ".evil.test/a", True), ("https://example.invalid/a", True)): + event["request"]["url"] = url + decision, error = acceptance.classify_request(event, "plugin-frame", "nonce", False) + self.assertEqual(decision, "continue") + self.assertEqual(error is not None, rejected) + + def test_only_exact_native_cors_preflight_is_released_without_command(self): + event = self.event() + event["request"] = {"url": acceptance.IPC_URL, "method": "OPTIONS", "headers": { + "Origin": acceptance.ORIGIN, "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type,tauri-callback,tauri-error,tauri-invoke-key"}} + self.assertEqual(acceptance.classify_request(event, "plugin-frame", "nonce", False), ("continue", None)) + self.assertIsNotNone(acceptance.classify_request(event, "plugin-frame", "nonce", True)[1]) + event["request"]["headers"]["Origin"] = "http://other.invalid" + self.assertIsNotNone(acceptance.classify_request(event, "plugin-frame", "nonce", False)[1]) + + def test_fault_fails_once_and_never_generalizes_second_request(self): + session = Mock() + fault = acceptance.FetchFault(session, "plugin-frame", "nonce") + with patch.object(acceptance, "cdp_send") as send: + fault.on_paused(self.event()) + fault.assert_one() + second = self.event() + second["requestId"] = "request-2" + fault.on_paused(second) + self.assertEqual([call.args[1] for call in send.call_args_list], ["Fetch.failRequest", "Fetch.continueRequest"]) + self.assertEqual(fault.failed, 1) + self.assertFalse(fault.paused) + with self.assertRaises(AssertionError): + fault.assert_one() + self.assertNotIn("ephemeral", json.dumps(fault.records)) + + def test_failed_release_is_retained_until_disable_and_detach(self): + session = Mock() + fault = acceptance.FetchFault(session, "plugin-frame", "nonce") + fault.enabled = True + session._impl_obj.detach = Mock(side_effect=lambda: __import__("asyncio").sleep(0)) + session._sync.side_effect = lambda coroutine: __import__("asyncio").run(coroutine) + with patch.object(acceptance, "cdp_send", side_effect=RuntimeError("send failed")): + fault.on_paused(self.event()) + self.assertEqual(fault.paused, {"request-1"}) + def send(_session, method, params=None): + if method == "Fetch.continueRequest": + raise RuntimeError("release failed") + with patch.object(acceptance, "cdp_send", side_effect=send) as calls: + with self.assertRaisesRegex(RuntimeError, "release failed"): + fault.close() + self.assertEqual([call.args[1] for call in calls.call_args_list], ["Fetch.continueRequest", "Fetch.disable"]) + session._impl_obj.detach.assert_called_once() + self.assertFalse(fault.enabled) + self.assertFalse(fault.paused) + + def test_disable_failure_still_detaches_and_is_not_success(self): + session = Mock() + session._impl_obj.detach = Mock(side_effect=lambda: __import__("asyncio").sleep(0)) + session._sync.side_effect = lambda coroutine: __import__("asyncio").run(coroutine) + fault = acceptance.FetchFault(session, "plugin-frame", "nonce") + fault.enabled = True + with patch.object(acceptance, "cdp_send", side_effect=RuntimeError("disable failed")): + with self.assertRaisesRegex(RuntimeError, "disable failed"): + fault.close() + session._impl_obj.detach.assert_called_once() + self.assertTrue(fault.enabled) + + def test_transport_mutation_and_unsettled_native_callbacks_are_rejected(self): + descriptor = {"writable": False, "configurable": False} + before = {"descriptors": {key: dict(descriptor) for key in ("lithe", "invoke", "ipc", "postMessage")}, + "url": acceptance.ORIGIN + "/ui/index.html", "bridgeFrozen": True, "pattern": "brownfield", + "callbacks": {"isMap": True, "size": 0}} + acceptance.assert_transport(before, before) + for changed in ("callbacks", "descriptors"): + after = copy.deepcopy(before) + if changed == "callbacks": + after[changed]["size"] = 1 + else: + after[changed]["invoke"]["writable"] = True + with self.assertRaises(AssertionError): + acceptance.assert_transport(before, after) + + def test_host_architecture_comes_from_real_pe_bytes(self): + with tempfile.TemporaryDirectory() as directory: + executable = Path(directory) / "controlled.exe" + header = bytearray(64) + header[:2] = b"MZ" + header[60:64] = (64).to_bytes(4, "little") + executable.write_bytes(header + b"PE\0\0\x64\x86") + self.assertEqual(acceptance.require_x64_executable(executable), "IMAGE_FILE_MACHINE_AMD64") + executable.write_bytes(header + b"PE\0\0\x64\xaa") + with self.assertRaisesRegex(ValueError, "not Windows x64"): + acceptance.require_x64_executable(executable) + + def test_complete_unsigned_static_fixture_rejects_repacked_extra_content(self): + parent = fixture.ROOT / ".artifacts/plugin-host-acceptance" + parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="web-bridge-fallback-fixture-test-", dir=parent) as temporary: + root = Path(temporary) / "inputs" + fixture.generate(root) + fixture.load_plan(root) + self.assertEqual(fixture.declaration()["requires"], {"ui": 2}) + self.assertEqual(set(fixture.declaration()["permissions"]), {"ui.contribute", "ui.web"}) + archive = root / "web-fallback.zip" + with __import__("zipfile").ZipFile(archive, "a") as output: + output.writestr("program.exe", b"not a static page") + with self.assertRaisesRegex(ValueError, "identity changed"): + fixture.load_plan(root) + + def test_first_identity_failure_survives_cleanup_and_cannot_pass_runtime_resources(self): + from acceptance_driver import Reporter + parent = fixture.ROOT / ".artifacts/plugin-host-acceptance" + parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="web-bridge-fallback-fixture-test-", dir=parent) as temporary: + root = Path(temporary) + fixture.generate(root / "inputs") + (root / "image").mkdir() + args = SimpleNamespace(fixture_root=root / "inputs", artifact_root=root / "evidence", run_image=root / "image", + host_identity=root / "identity.json", executable=root / "image/host.exe", app_local_data=root / "local/app.lithe.windows.host-acceptance", + app_roaming_data=root / "roaming/app.lithe.windows.host-acceptance", webview_data=root / "runtime/webview2") + driver = MagicMock() + driver.owned_processes = {} + driver.ACCEPTANCE_IDENTIFIER = "app.lithe.windows.host-acceptance" + driver.sync_playwright.return_value.__exit__.side_effect = RuntimeError("observer teardown failed") + def persist(path, value): + if path.name == "console.json": + raise OSError("evidence write failed") + driver.write_json.side_effect = persist + reporter = Reporter() + with patch.object(acceptance, "verify_host", side_effect=ValueError("first identity failure")), contextlib.redirect_stdout(io.StringIO()): + with self.assertRaisesRegex(ValueError, "first identity failure"): + acceptance.run(args, driver, reporter) + driver.AppSession.assert_not_called() + driver.safe_remove_owned_directory.assert_called() + row = next(row for row in reporter.tests if row["name"].endswith(" / resource-invariance")) + self.assertEqual(row["status"], "failed") + self.assertIn("native workflow/shutdown did not complete", row["details"]) + failure = next(call.args[1] for call in driver.write_json.call_args_list if call.args[0].name == "first-failure.json") + self.assertEqual(failure["message"], "first identity failure") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--case") + parser.add_argument("--report", type=Path) + args = parser.parse_args() + if args.case: + result = unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromName(args.case, sys.modules[__name__])) + return 0 if result.wasSuccessful() else 1 + if args.report is None: + parser.error("--report is required") + rows = [] + for name in unittest.defaultTestLoader.getTestCaseNames(WebFallbackHelperTests): + started = time.monotonic() + status, details, code = "passed", "", None + command = [sys.executable, "-B", str(Path(__file__).resolve()), "--case", "WebFallbackHelperTests." + name] + try: + result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10) + code = result.returncode + if code: + status, details = "failed", result.stdout + result.stderr + except subprocess.TimeoutExpired: + status, details = "timeout", "helper exceeded its ten-second subprocess deadline" + duration = round((time.monotonic() - started) * 1000, 3) + if duration >= 10_000: + status = "timeout" + rows.append({"name": name, "suite": "plugin-host-acceptance / web-fallback-helpers-only", "status": status, + "durationMs": duration, "maxMs": 10_000, "details": details, "command": command, "exitCode": code}) + print(f"{status.upper()} {name} ({duration} ms) {details}", flush=True) + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_bytes(fixture.json_bytes({"runner": "acceptance", "warnMs": 2000, "maxMs": 10_000, "tests": rows})) + return int(any(row["status"] != "passed" for row in rows)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/web_bridge_fallback_fixture.py b/scripts/plugin-host-acceptance/web_bridge_fallback_fixture.py new file mode 100644 index 0000000000..1d907df3fd --- /dev/null +++ b/scripts/plugin-host-acceptance/web_bridge_fallback_fixture.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Complete unsigned ui:2 inputs, generated per run and never reused as a cache.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import subprocess +import time +import zipfile +from pathlib import Path + +from build_fixture import ROOT, confined, json_bytes, ordinary_tree, write_archive +from settings_functions_fixture import sha256, source_revision + +PLUGIN_ID = "io.github.lithe-acceptance.web-fallback" +WINDOW_ID = PLUGIN_ID + ".window" +DISPLAY_NAME = "Lithe Web Fallback Acceptance" +FILES = {"ui/index.html": b'Controlled web fallback
    Controlled unsigned ui:2 state bridge fixture
    \n'} + + +def declaration() -> dict: + return {"schemaVersion": 2, "id": PLUGIN_ID, "displayName": DISPLAY_NAME, + "version": "1.0.0", "pluginAPIVersion": 3, "requires": {"ui": 2}, + "vendor": {"id": "io.github.lithe-acceptance", "displayName": "Unsigned controlled fixture", + "signatureRequirement": "none"}, + "permissions": ["ui.contribute", "ui.web"], "activationEvents": ["onView:" + WINDOW_ID], + "languages": [], "ui": {"toolWindows": [{"id": WINDOW_ID, "title": DISPLAY_NAME, + "placement": "right", "order": 300, "web": {"entry": "ui/index.html"}}]}} + + +def check_output(root: Path) -> None: + parent = ROOT / ".artifacts/plugin-host-acceptance" + if parent.resolve() not in root.resolve().parents or not root.resolve().relative_to(parent.resolve()).parts[0].startswith("web-bridge-fallback-fixture"): + raise ValueError("use a run-owned web-bridge-fallback-fixture below .artifacts/plugin-host-acceptance") + for path in (root, *root.parents): + if path.is_symlink() or path.is_junction(): + raise ValueError("fixture path cannot traverse a link") + + +def generate(root: Path) -> dict: + check_output(root) + root.mkdir(parents=True, exist_ok=False) + archive = root / "web-fallback.zip" + write_archive(archive, {"plugin.json": json_bytes(declaration()), **FILES}) + (root / "workspace").mkdir() + plan = {"schemaVersion": 1, "kind": "controlled-unsigned-ui2-web-fallback", "pluginId": PLUGIN_ID, + "sourceCommit": source_revision(), "generatorSha256": sha256(Path(__file__)), + "archive": {"path": archive.name, "sha256": sha256(archive)}, "requires": {"ui": 2}} + (root / "plan.json").write_bytes(json_bytes(plan)) + return plan + + +def load_plan(root: Path) -> dict: + check_output(root) + ordinary_tree(root) + plan = json.loads((root / "plan.json").read_text(encoding="utf-8")) + expected = {"schemaVersion": 1, "kind": "controlled-unsigned-ui2-web-fallback", "pluginId": PLUGIN_ID, + "sourceCommit": source_revision(), "generatorSha256": sha256(Path(__file__)), + "archive": {"path": "web-fallback.zip", "sha256": sha256(root / "web-fallback.zip")}, "requires": {"ui": 2}} + if plan != expected: + raise ValueError("fixture source/manifest/archive identity changed") + with zipfile.ZipFile(confined(root, plan["archive"]["path"])) as archive: + files = {"plugin.json": json_bytes(declaration()), **FILES} + if sorted(archive.namelist()) != sorted(files) or any(archive.read(name) != data for name, data in files.items()): + raise ValueError("complete controlled fixture content changed") + return plan + + +def source_inventory(*, deadline: float | None = None) -> dict: + # Cover the entire Windows build graph and its contracts/tooling, including + # root lock/config and third-party inputs. Untracked, non-ignored sources + # can also be compiled; generated dependencies/outputs follow Git ignores. + # macOS-only sources are not compiled in this host. + deadline = deadline if deadline is not None else time.monotonic() + 300 + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("full Windows source inventory exceeded 300 seconds") + result = subprocess.run(["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", + "windows", "frontend", "rust", "Plugins/win", "shared", "scripts", "third_party", + "Cargo.lock", "Cargo.toml", "bun.lock", "bunfig.toml", "package.json"], + cwd=ROOT, capture_output=True, check=True, timeout=min(15, remaining)) + inventory = {} + for name in sorted(result.stdout.decode().split("\0")): + if name and (ROOT / name).is_file(): + digest = hashlib.sha256() + with (ROOT / name).open("rb") as stream: + while True: + if time.monotonic() >= deadline: + raise TimeoutError("full Windows source inventory exceeded 300 seconds") + chunk = stream.read(1024 * 1024) + if not chunk: + break + digest.update(chunk) + inventory[name] = digest.hexdigest() + return inventory + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", required=True, type=Path) + print(json.dumps(generate(parser.parse_args().output), indent=2)) diff --git a/scripts/prepare-jdtls.ps1 b/scripts/prepare-jdtls.ps1 index feee32796c..6d8eb924a8 100644 --- a/scripts/prepare-jdtls.ps1 +++ b/scripts/prepare-jdtls.ps1 @@ -152,6 +152,14 @@ if ($usesExistingRoot) { exit 0 } +# MSYS/GNU tar can interpret an absolute Windows drive prefix as a remote host. +# Resolve the Windows-owned executable independently of PATH before downloading +# resources or replacing an existing output tree. +$tarExecutable = Join-Path ([Environment]::SystemDirectory) "tar.exe" +if (-not (Test-Path -LiteralPath $tarExecutable -PathType Leaf)) { + throw "Native Windows tar was not found: $tarExecutable" +} + New-Item -ItemType Directory -Force -Path $cache | Out-Null if ($archiveUsesOverride) { if (-not (Test-Path -LiteralPath $archive -PathType Leaf)) { throw "JDTLS archive was not found: $archive" } @@ -170,7 +178,12 @@ Get-VerifiedDownload -Uri $manifest.javaTestLicenseURL -ExpectedSHA256 $javaTest if (Test-Path -LiteralPath $output) { Remove-Item -Recurse -Force -LiteralPath $output } New-Item -ItemType Directory -Force -Path $output | Out-Null -tar.exe -xzf $archive -C $output +& $tarExecutable -xzf $archive -C $output +if ($LASTEXITCODE -ne 0) { + # Leave the original native diagnostic in the error stream and stop before + # copying licenses or generating launchers into a partial extraction. + throw "JDTLS archive extraction failed (native tar exit $LASTEXITCODE): $archive" +} Copy-Item -LiteralPath $license -Destination (Join-Path $output "LICENSE-EPL-2.0.txt") -Force Copy-Item -LiteralPath $manifestPath -Destination (Join-Path $output "manifest.json") -Force $lombokOutput = Join-Path $output "lombok" diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index c1bef75781..cb812960d2 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -83,6 +83,23 @@ async function readRegistry() { // both original state and migration snapshots and must not gain a validator. // Shared i18n source and generated tables take this same refusal route: // rebuild from the target checkout, never copy another worktree's translations. + // Windows source lists, raw index caches, digest-bound download files and + // per-workspace build options use plugin-marketplace-state's refusal route. + // Window/operation-owned model/dependency files stay in mutable plugin state + // and use official-plugin-packages' exclusion; decoded output is not a reusable artifact. + // Core validation or a matching archive hash never transfers user ownership. + // Native acceptance fixtures/profiles use plugin-host-acceptance-state's + // refusal route, including build-fixture tools/workspaces and settings/functions + // input plans, ZIPs and resource snapshots, plus ui:2 web fallback static ZIPs, + // private-target CDP traces and descriptor/callback snapshots; ui:1 text-window + // ZIPs, host-page DOM/CSP/request traces and loopback-counter evidence are also run-owned; deterministic test ZIPs do not + // establish a build identity or transfer native install approval. + // Offline preflight fake tools/manifests and tar.gz/VSIX extraction fixtures in OS temporary storage also remain + // plugin-host-acceptance-state; they have no downloadable resource validator. + // Installer test scratch, generated native-handle waiters and ownership/identity + // receipts use frontend-install-state's refusal route; process identity is not a cache stamp. + // Controlled action-group/form-grid/controls-input/workbench/tree browser scratch, ownership state, copied logs and expected-red evidence use + // native-browser-regression-state's refusal route; no build stamp or validator applies. const excludedResources = registry.excludedResources ?? []; if (!Array.isArray(excludedResources)) throw new Error("Invalid excluded worktree resources"); for (const resource of excludedResources) { @@ -180,6 +197,9 @@ async function validatorArguments(resource, cachePath, targetRoot) { if (resource.validator === "bun") { // This route validates sealed downloads only. Job-owned installation temp // data and node_modules stay in the registry's non-reusable exclusions. + // Exact lock bytes include source URLs: switching registry with an unchanged + // dependency graph still rejects the old seal. GitHub lifecycle binaries have + // no validator here; their identity cannot be inferred from npm tarball SRI. const packagePath = await requiredFile(targetRoot, "package.json", resource.id); const packageManifest = JSON.parse(await fs.readFile(packagePath, "utf8")); const match = String(packageManifest.packageManager ?? "").match(/^bun@(.+)$/); @@ -381,6 +401,13 @@ async function main() { // and AutoBuild staging (including per-tag baselines and local/downloaded publication checkpoints), per-launch // Codex relays and isolated plugin packages take this rejection route, never a content-hash validator. // Identical bytes do not establish transferable execution ownership. + // Includes native-browser-regression-state: browser profiles, optimizer caches, + // worker/Job evidence, copied logs and expected-failure reports stay with + // their run; never enter a copy stage. + // Includes plugin-host-acceptance-state: fixed-source SVN diagnostic runtime, + // repo/WC/profiles, offline preflight fake tools/manifests/archive inputs and explicit + // evidence output stay run-owned, never reusable. + // SwiftPM scratch-local compiler stamps only invalidate local products; // swiftpm-build-state is not a sealed download cache and has no copy stage. // Includes application-log-state: logs, confirmation snapshots and export ZIP @@ -402,6 +429,12 @@ async function main() { // Includes mcp-registry-metadata: responses have no copy stage; featured.json // and McpIcons follow Git source, never a downloaded/generated resource validator. // Includes plugin-web-state: per-window/workspace user JSON has no build identity or copy stage. + // Includes vcs-root-mappings: directory-bound user choices and watcher leases + // have no build stamp, validator, or copy stage; never share them across worktrees. + // Includes plugin-settings-state: confirmed user preferences, replacement + // temporaries and recovery backups have no validator or copy stage. + // Includes plugin-generator-transactions: sibling staging and published user + // projects keep their native operation owner; no validator or copy stage applies. // Includes remote-server-artifacts: packaging rebuilds them from this worktree's sources. // Includes remote-connection-state: SSH settings, keychain secrets and placeholder roots are user-owned. // Includes plugin-disk-install-state: fileStorage temporary snapshots, install staging @@ -412,6 +445,8 @@ async function main() { // Per-launch Codex helpers also own memory-only context replay tickets and // loopback capabilities; they stay on this exclusion route, never a copy stage. // Agent edit-and-paste state is user-owned runtime data, without a copy stage. + // Includes project-generator-transactions: operation-owned staging, ZIPs + // and reviews have no build identity, even after Core extraction succeeds. if (excludedResources.some((resource) => resource.id === identifier)) { const excluded = excludedResources.find((resource) => resource.id === identifier); throw new Error(`Resource ${identifier} is isolated (${excluded.locations.join(", ")}): ${excluded.identity}; ${excluded.reason}; it cannot be reused across worktrees`); diff --git a/scripts/test-plugin-vcs-move-diagnostic.mjs b/scripts/test-plugin-vcs-move-diagnostic.mjs new file mode 100644 index 0000000000..f376a02fb2 --- /dev/null +++ b/scripts/test-plugin-vcs-move-diagnostic.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +// Offline diagnostic assertions only; never launch an installed Provider or SVN. +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {runProcess} from '../.agents/skills/write-stable-tests/scripts/test-timing-lib.mjs'; +import {parseJUnitCases} from '../.agents/skills/write-stable-tests/scripts/parse-junit-cases.mjs'; +import {writeTestReportArtifacts} from '../.agents/skills/write-stable-tests/scripts/generate-test-report.mjs'; + +const args = process.argv.slice(2); +if (args.length !== 2 || args[0] !== '--report' || !args[1].endsWith('.json')) { + throw new Error('Usage: node scripts/test-plugin-vcs-move-diagnostic.mjs --report '); +} +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const report = path.resolve(args[1]); +const result = await runProcess({command: process.execPath, + args: ['--test', '--test-timeout=15000', '--test-reporter=junit', + path.join(root, 'scripts/plugin-host-acceptance/vcs-move-diagnostic_test.mjs')], + cwd: root, timeoutMs: 60000}); +const tests = parseJUnitCases(result.stdout); +if (!tests.length || result.code !== 0 || result.timedOut) { + tests.push({name: 'diagnostic assertion runner', status: result.timedOut ? 'timeout' : 'failed', + durationMs: result.durationMs, maxMs: 60000, + details: `exit=${result.code}, timedOut=${result.timedOut}; ${result.stderr}`}); +} +fs.mkdirSync(path.dirname(report), {recursive: true}); +fs.writeFileSync(report.replace(/\.json$/, '.log'), result.stdout + result.stderr); +fs.writeFileSync(report, JSON.stringify({schemaVersion: 1, runner: 'node', warnMs: 1000, + maxMs: 15000, suite: {timedOut: result.timedOut, durationMs: result.durationMs}, tests}, null, 2) + '\n'); +writeTestReportArtifacts(report); +console.log(`Recorded ${tests.length} diagnostic assertion results.`); +if (tests.some(test => !['passed', 'skipped'].includes(test.status) + || (test.status === 'passed' && test.durationMs >= (test.maxMs ?? 15000)))) process.exitCode = 1; diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index 6cd0a1af14..f2c3e6fc64 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -5,7 +5,7 @@ import test from "node:test"; import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { spawn, spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { publishDirectory } from "./reuse-worktree-resources.mjs"; @@ -64,6 +64,32 @@ function reuse(extraArguments = []) { } try { + await test("VCS root choices and watcher ownership cannot be reused", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "vcs-root-mappings"); + assert.equal(resource.reusable, false); + assert.ok(resource.locations.some(location => location.includes("vcs-roots/"))); + assert.ok(resource.locations.some(location => location.endsWith(".backup"))); + assert.match(resource.identity, /directory identity/); + assert.match(resource.reason, /permissions/); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /vcs-root-mappings.*cannot be reused/); + }); + await test("npm source identity cannot authorize separate lifecycle binary reuse", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const bun = registry.resources.find(item => item.id === "bun"); + assert.match(bun.identity, /source-only lock migration invalidates/); + const state = registry.excludedResources.find(item => item.id === "frontend-install-state"); + assert.ok(state.locations.includes("/lithe Windows install */")); + assert.match(state.reason, /native-handle waiter/); + assert.match(state.reason, /null digest cannot establish a binary reuse identity/); + assert.ok(!registry.resources.some(item => /tree-sitter/.test(item.id))); + const refused = reuse(["--resource", state.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /frontend-install-state.*cannot be reused/); + }); + await test("SwiftPM build products and compiler stamps cannot be reused", { timeout: 15000 }, async () => { const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); const resource = registry.excludedResources.find(item => item.id === "swiftpm-build-state"); @@ -240,6 +266,34 @@ try { assert.notEqual(refused.status, 0); assert.match(diagnostics(refused), /jdt-maven-settings.*cannot be reused/); }); + await test("plugin settings and replacement recovery files stay user-owned", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "plugin-settings-state"); + assert.equal(resource.reusable, false); + assert.ok(resource.locations.includes("/.lithe/plugin-settings.json")); + assert.ok(resource.locations.some(location => location.endsWith(".backup"))); + assert.match(resource.reason, /permissions/); + const listed = reuse(["--list"]); + assertSucceeded(listed); + assert.ok(!listed.stdout.includes(resource.id)); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /plugin-settings-state.*cannot be reused/); + }); + await test("generator staging and user projects cannot be copied as build resources", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "plugin-generator-transactions"); + assert.equal(resource.reusable, false); + assert.ok(resource.locations.includes("/..lithe-creating-/")); + assert.match(resource.identity, /operation/); + assert.match(resource.reason, /NativeLease/); + const listed = reuse(["--list"]); + assertSucceeded(listed); + assert.ok(!listed.stdout.includes(resource.id)); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /plugin-generator-transactions.*cannot be reused/); + }); await test("plugin web state has no transferable build identity or copy stage", { timeout: 15000 }, async () => { const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); const resource = registry.excludedResources.find(item => item.id === "plugin-web-state"); @@ -274,6 +328,149 @@ try { assert.match(diagnostics(refused), new RegExp(`${id}.*cannot be reused`)); } }); + await test("command model and dependency output use the isolated plugin-state route", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "official-plugin-packages"); + assert.equal(resource.reusable, false); + assert.ok(resource.locations.includes("/plugin-state//build--.model.json")); + assert.ok(resource.locations.includes("/plugin-state//build--.dependencies.json")); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /official-plugin-packages.*cannot be reused/); + }); + await test("controlled browser profiles, caches and ownership evidence cannot be reused", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "native-browser-regression-state"); + assert.equal(resource.reusable, false); + for (const location of [ + ".artifacts/native-browser-*/profile/", ".artifacts/native-browser-*/cache/", + ".artifacts/native-browser-*/worker-evidence.json", ".artifacts/native-browser-*/job-result.json", + ".artifacts/native-browser-*/worker.log", + ".artifacts/test-stability/native-panel-browser.{json,html,junit.xml}", + ".artifacts/test-stability/native-form-grid-browser.{json,html,junit.xml,worker.log}", + ".artifacts/test-stability/native-controls-input-browser.{json,html,junit.xml,worker.log}", + ".artifacts/test-stability/native-workbench-browser.{json,html,junit.xml,worker.log}", + ".{json,html,junit.xml,worker.log}", + "/expected-failures/.{json,html,junit.xml}", + ]) assert.ok(resource.locations.includes(location)); + for (const fixture of [ + ".artifacts/native-browser-first/profile/Preferences", + ".artifacts/native-browser-first/cache/deps/_metadata.json", + ".artifacts/native-browser-first/worker-evidence.json", + ".artifacts/native-browser-first/job-result.json", + ".artifacts/native-browser-first/worker.log", + ".artifacts/test-stability/native-panel-browser.json", + ".artifacts/test-stability/native-panel-browser.html", + ".artifacts/test-stability/native-panel-browser.junit.xml", + ".artifacts/test-stability/native-panel-browser.worker.log", + ".artifacts/test-stability/native-tree-browser.json", + ".artifacts/test-stability/native-form-grid-browser.json", + ".artifacts/test-stability/native-form-grid-browser.worker.log", + ".artifacts/test-stability/native-controls-input-browser.json", + ".artifacts/test-stability/native-controls-input-browser.worker.log", + ".artifacts/test-stability/native-workbench-browser.json", + ".artifacts/test-stability/native-workbench-browser.worker.log", + ".artifacts/test-stability/native-browser-worker-hang.worker.log", + ".artifacts/test-stability/expected-failures/native-browser-worker-exit.json", + ".artifacts/test-stability/expected-failures/native-browser-worker-hang.html", + ]) { + const source = path.join(sourceRoot, fixture); + await fs.mkdir(path.dirname(source), { recursive: true }); + await fs.writeFile(source, "run-owned browser bytes"); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /native-browser-regression-state.*cannot be reused/); + assert.equal(await fs.readFile(source, "utf8"), "run-owned browser bytes"); + await assert.rejects(fs.access(path.join(targetRoot, fixture))); + } + }); + await test("offline build-preflight fake tools and mutable manifests cannot transfer ownership", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "plugin-host-acceptance-state"); + assert.equal(resource.reusable, false); + assert.ok(resource.locations.includes("/lithe-build-preflight-*/")); + assert.match(resource.reason, /fake native Bun\/IPC workers/); + assert.match(resource.reason, /tar.gz\/VSIX extraction inputs and outputs/); + // Refuse before worktree resolution or any copy stage, even if the source + // looks like a complete disposable checkout with an executable and manifest. + for (const fixture of [ + "preflight-fixture/native/bun.exe", + "preflight-fixture/fake-path/tar.exe", + "preflight-fixture/jdtls-downloads/small-fixture.tar.gz", + "preflight-fixture/jdtls-downloads/java-test.zip", + "preflight-fixture/windows/tauri/src-tauri/Cargo.toml", + "preflight-fixture/scripts/install-windows-frontend-dependencies.ps1", + ]) { + const source = path.join(sourceRoot, fixture); + await fs.mkdir(path.dirname(source), { recursive: true }); + await fs.writeFile(source, "isolated offline preflight fixture bytes"); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /plugin-host-acceptance-state.*cannot be reused/); + assert.equal(await fs.readFile(source, "utf8"), "isolated offline preflight fixture bytes"); + await assert.rejects(fs.access(path.join(targetRoot, fixture))); + } + }); + await test("explicit SVN diagnostic state remains excluded from worktree reuse", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "plugin-host-acceptance-state"); + assert.equal(resource.reusable, false); + assert.ok(resource.locations.includes("/")); + assert.ok(resource.locations.some(location => location.startsWith("/vcs-move-"))); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /plugin-host-acceptance-state.*cannot be reused/); + }); + await test("native acceptance refuses copying even a deterministic marketplace fixture", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "plugin-host-acceptance-state"); + assert.equal(resource.reusable, false); + assert.ok(resource.locations.includes(".artifacts/plugin-host-acceptance/")); + const fixture = ".artifacts/plugin-host-acceptance/marketplace-fixture/packages/a1.zip"; + const source = path.join(sourceRoot, fixture); + await fs.mkdir(path.dirname(source), { recursive: true }); + await fs.writeFile(source, "deterministic fixture bytes"); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /plugin-host-acceptance-state.*cannot be reused/); + assert.equal(await fs.readFile(source, "utf8"), "deterministic fixture bytes"); + await assert.rejects(fs.access(path.join(targetRoot, fixture))); + }); + await test("controlled build fixtures remain isolated acceptance state", { timeout: 15000 }, async () => { + const fixture = ".artifacts/plugin-host-acceptance/build-fixture/tools with spaces/bin/fake-build-tool.mjs"; + const source = path.join(sourceRoot, fixture); + await fs.mkdir(path.dirname(source), { recursive: true }); + await fs.writeFile(source, "controlled build fixture bytes"); + const refused = reuse(["--resource", "plugin-host-acceptance-state"]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /plugin-host-acceptance-state.*cannot be reused/); + assert.equal(await fs.readFile(source, "utf8"), "controlled build fixture bytes"); + await assert.rejects(fs.access(path.join(targetRoot, fixture))); + }); + await test("settings/functions and web fallback plans cannot transfer install approval", { timeout: 15000 }, async () => { + for (const fixture of [ + ".artifacts/plugin-host-acceptance/text-window-fixture-first/plan.json", + ".artifacts/plugin-host-acceptance/text-window-fixture-first/literal-text.zip", + ".artifacts/plugin-host-acceptance/text-window-evidence-first/network-and-ui-events.json", + ".artifacts/plugin-host-acceptance/text-window-evidence-first/counter-final.json", + ".artifacts/plugin-host-acceptance/web-bridge-fallback-fixture/plan.json", + ".artifacts/plugin-host-acceptance/web-bridge-fallback-fixture/web-fallback.zip", + ".artifacts/plugin-host-acceptance/evidence-r18/paused-requests.json", + ".artifacts/plugin-host-acceptance/evidence-r18/transport-after.json", + ".artifacts/plugin-host-acceptance/settings-functions-fixture/settings-functions-plan.json", + ".artifacts/plugin-host-acceptance/settings-functions-fixture/settings-1.0.0.zip", + ".artifacts/plugin-host-acceptance/evidence/settings-functions/resources-before.json", + ]) { + const source = path.join(sourceRoot, fixture); + await fs.mkdir(path.dirname(source), { recursive: true }); + await fs.writeFile(source, "run-owned settings/functions bytes"); + const refused = reuse(["--resource", "plugin-host-acceptance-state"]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /plugin-host-acceptance-state.*cannot be reused/); + assert.equal(await fs.readFile(source, "utf8"), "run-owned settings/functions bytes"); + await assert.rejects(fs.access(path.join(targetRoot, fixture))); + } + }); await test("retained MCP filters and native review screenshots remain user/run state", { timeout: 15000 }, async () => { const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); const policies = registry.excludedResources.find(item => item.id === "mcp-tool-probe-state"); @@ -324,6 +521,38 @@ try { await assert.rejects(fs.access(missingTarget), { code: "ENOENT" }); } }); + await test("project archive transactions are rejected before resolving either worktree", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "project-generator-transactions"); + assert.equal(resource.reusable, false); + assert.ok(resource.locations.includes("/..lithe-creating-/")); + assert.ok(resource.locations.includes("/..lithe-creating-/.lithe-project-archive-.zip")); + const listed = run(process.execPath, [reuseScript, "--list"]); + assertSucceeded(listed); + assert.ok(!listed.stdout.includes(resource.id)); + const missingSource = path.join(testRoot, "unopened-generator-source"); + const missingTarget = path.join(testRoot, "unopened-generator-target"); + const refused = run(process.execPath, [reuseScript, "--source", missingSource, "--target", missingTarget, + "--resource", "jdtls", "--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /project-generator-transactions is isolated.*cannot be reused/); + await assert.rejects(fs.access(missingSource), { code: "ENOENT" }); + await assert.rejects(fs.access(missingTarget), { code: "ENOENT" }); + }); + await test("Windows plugin indexes downloads and build options are isolated runtime state", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "plugin-marketplace-state"); + assert.equal(resource.reusable, false); + for (const location of [ + "/plugin-marketplace/sources.json", + "/plugin-marketplace/..json", + "/marketplace-download--.zip", + "/plugin-build-options/.json", + ]) assert.ok(resource.locations.includes(location), location); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /plugin-marketplace-state is isolated.*cannot be reused/); + }); await testFailedBackupPreservesDestination(); await fs.mkdir(path.join(sourceRoot, "third_party", "jdtls"), { recursive: true }); await fs.writeFile( @@ -367,11 +596,11 @@ try { const staleLock = path.join(targetRoot, ".artifacts", ".reuse-worktree-resources.lock"); await fs.mkdir(staleLock, { recursive: true }); - const exitedProcess = spawn(process.execPath, ["-e", ""], { stdio: "ignore" }); - await new Promise((resolve, reject) => { - exitedProcess.once("error", reject); - exitedProcess.once("exit", resolve); - }); + // The synchronous helper has a deadline and closes the process handle before + // publishing its PID. Windows may still observe an exited async child's + // handle as live before its close event, making the stale-lock test race. + const exitedProcess = run(process.execPath, ["-e", ""]); + assertSucceeded(exitedProcess); await fs.writeFile( path.join(staleLock, "owner.json"), `${JSON.stringify({ pid: exitedProcess.pid, startedAt: new Date().toISOString() })}\n`, diff --git a/scripts/test-windows-declarative-plugin-host.ps1 b/scripts/test-windows-declarative-plugin-host.ps1 new file mode 100644 index 0000000000..589137d582 --- /dev/null +++ b/scripts/test-windows-declarative-plugin-host.ps1 @@ -0,0 +1,649 @@ +[CmdletBinding()] +param( + [ValidateSet("x86_64-pc-windows-msvc")] + [string]$RustTarget = "x86_64-pc-windows-msvc", + [switch]$SkipBuild, + [switch]$ResumeBuild, + [string]$ResourceSource = "", + [string]$NativeBunPath = "", + # Per install attempt (one clean retry); retains the installer default. + [ValidateRange(1, 1200)][int]$InstallTimeoutSeconds = 300, + [switch]$Marketplace, + [switch]$LspLifecycle, + [switch]$SettingsFunctions, + [string]$SettingsFunctionsFixtureRoot = "", + [switch]$TextWindow, + [string]$MarketplaceFixtureRoot = "", + [string]$MarketplaceTlsCert = "", + [string]$MarketplaceTlsKey = "", + [string]$MarketplaceListenHost = "127.0.0.1" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest +if (-not $IsWindows) { throw "The declarative plugin host acceptance requires Windows." } +if ($SkipBuild -and $ResumeBuild) { throw "Choose either -SkipBuild or -ResumeBuild." } +if ($SettingsFunctions -ne (-not [string]::IsNullOrWhiteSpace($SettingsFunctionsFixtureRoot))) { + throw "Use -SettingsFunctions and -SettingsFunctionsFixtureRoot together (generate settings_functions_fixture.py inputs first)." +} +if ($SettingsFunctions -and ($Marketplace -or $LspLifecycle)) { + throw "Settings/functions is a focused invocation; run marketplace/LSP acceptance separately." +} +if ($SettingsFunctions -and -not $SkipBuild) { + throw "Settings/functions consumes an existing isolated host: use -SkipBuild to preserve its generated inputs and build identity." +} +if ($TextWindow -and ($Marketplace -or $LspLifecycle -or $SettingsFunctions)) { + throw "Text-window acceptance is a focused invocation; run marketplace/LSP/settings acceptance separately." +} +$controlledMarketplaceArguments = @($MarketplaceFixtureRoot, $MarketplaceTlsCert, $MarketplaceTlsKey) +$providedMarketplaceArguments = @($controlledMarketplaceArguments | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +if ($providedMarketplaceArguments.Count -gt 0 -and (-not $Marketplace -or $providedMarketplaceArguments.Count -ne 3)) { + throw "Controlled marketplace acceptance requires -Marketplace, -MarketplaceFixtureRoot, -MarketplaceTlsCert and -MarketplaceTlsKey together." +} + +$root = [IO.Path]::GetFullPath((Split-Path -Parent $PSScriptRoot)) +$artifactParent = [IO.Path]::GetFullPath((Join-Path $root ".artifacts")) +$artifactRoot = [IO.Path]::GetFullPath((Join-Path $artifactParent "plugin-host-acceptance")) +$stabilityRoot = [IO.Path]::GetFullPath((Join-Path $artifactParent "test-stability")) +$fixtureRoot = Join-Path $root "scripts/plugin-host-acceptance" +$profileRoot = [IO.Path]::GetFullPath( + (Join-Path $root "windows/tauri/src-tauri/target/$RustTarget/release") +) +$sourceExecutable = Join-Path $profileRoot "lithe-windows.exe" +$identityMarker = Join-Path $artifactRoot "build-identity.json" +$customConfig = Join-Path $artifactRoot "tauri.host-acceptance.conf.json" +$acceptanceIdentifier = "app.lithe.windows.host-acceptance" + +function Assert-OwnedPath { + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Parent) + $resolved = [IO.Path]::GetFullPath($Path) + $resolvedParent = [IO.Path]::GetFullPath($Parent).TrimEnd('\') + '\' + if (-not $resolved.StartsWith($resolvedParent, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to manage a path outside $Parent`: $resolved" + } +} + +function Reset-OwnedDirectory { + param([Parameter(Mandatory)][string]$Path) + Assert-OwnedPath -Path $Path -Parent $artifactParent + if (Test-Path -LiteralPath $Path) { + Remove-Item -LiteralPath $Path -Recurse -Force + } + New-Item -ItemType Directory -Path $Path -Force | Out-Null +} + +function Invoke-NativeChecked { + param( + [Parameter(Mandatory)][string]$Executable, + [Parameter(Mandatory)][string[]]$Arguments, + [Parameter(Mandatory)][string]$FailureMessage + ) + & $Executable @Arguments + if ($LASTEXITCODE -ne 0) { throw "$FailureMessage (exit $LASTEXITCODE)" } +} + +function Invoke-BoundedDriver { + param([string[]]$Arguments, [string]$LogPath, [int]$TimeoutSeconds = 900) + $startInfo = [Diagnostics.ProcessStartInfo]::new((Get-Command python).Source) + $startInfo.WorkingDirectory = $root + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) } + $driver = [Diagnostics.Process]::new() + $driver.StartInfo = $startInfo + $started = $false + try { + $started = $driver.Start() + $stdout = $driver.StandardOutput.ReadToEndAsync() + $stderr = $driver.StandardError.ReadToEndAsync() + if (-not $driver.WaitForExit($TimeoutSeconds * 1000)) { + throw "The native acceptance driver exceeded its $TimeoutSeconds second deadline." + } + if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout, $stderr), 5000)) { + throw "The native acceptance driver output did not close within 5 seconds." + } + $output = $stdout.GetAwaiter().GetResult() + $stderr.GetAwaiter().GetResult() + $output | Set-Content -LiteralPath $LogPath -Encoding utf8NoBOM + Write-Host $output + return $driver.ExitCode + } finally { + try { + if ($started -and -not $driver.HasExited) { + $driver.Kill($true) + if (-not $driver.WaitForExit(5000)) { throw "The acceptance driver survived bounded cleanup." } + } + } finally { + $driver.Dispose() + } + } +} + +function Write-NormalizedReport { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$Status, + [Parameter(Mandatory)][string]$Details + ) + @{ + runner = "acceptance" + warnMs = 1000 + maxMs = 15000 + tests = @(@{ + name = $Name + suite = "plugin-host-acceptance / source-guard" + status = $Status + durationMs = 0 + maxMs = 15000 + details = $Details + }) + } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $Path -Encoding utf8NoBOM +} + +function Save-TextWindowSourceInventory { + param([Parameter(Mandatory)][string]$Path) + $code = "import sys,json,pathlib; sys.path.insert(0,sys.argv[1]); from text_window_fixture import source_inventory; pathlib.Path(sys.argv[2]).write_text(json.dumps(source_inventory(),sort_keys=True),encoding='utf-8')" + $result = Invoke-BoundedDriver -Arguments @("-B", "-c", $code, $fixtureRoot, $Path) ` + -LogPath (Join-Path $artifactRoot "text-window-source-inventory.log") -TimeoutSeconds 330 + if ($result -ne 0) { throw "The complete text-window source inventory failed." } +} + +function Assert-TextWindowSourceInventory { + param([Parameter(Mandatory)][string]$Path, [switch]$Identity) + # Compare dictionaries in Python, independent of PowerShell property order. + # A legacy marker without an inventory must never be upgraded after building. + $code = @' +import sys,json,pathlib +sys.path.insert(0,sys.argv[1]) +from text_window_fixture import source_inventory +expected=json.loads(pathlib.Path(sys.argv[2]).read_text(encoding='utf-8-sig')) +expected=expected.get('sourceInventory') if sys.argv[3]=='identity' else expected +current=source_inventory() +if not isinstance(expected,dict) or expected!=current: + raise ValueError('text-window build source inventory is missing or changed') +'@ + $kind = if ($Identity) { "identity" } else { "inventory" } + $result = Invoke-BoundedDriver -Arguments @("-B", "-c", $code, $fixtureRoot, $Path, $kind) ` + -LogPath (Join-Path $artifactRoot "text-window-source-identity-check.log") -TimeoutSeconds 330 + if ($result -ne 0) { throw "The text-window build source inventory is missing or changed." } +} + +function Stop-OwnedAcceptanceProcesses { + param([Parameter(Mandatory)][string]$StagedExecutable) + $executablePath = [IO.Path]::GetFullPath($StagedExecutable) + # Share the driver's upstream native inspector. Full CIM enumeration can + # hang on an unrelated process even when every owned process has exited. + $inventoryPath = Join-Path $evidenceRoot "wrapper-process-inventory.json" + $inventoryCode = "import sys,json,pathlib; sys.path.insert(0,sys.argv[1]); import acceptance_driver as d; print(json.dumps(d.acceptance_process_inventory(pathlib.Path(sys.argv[2]),sys.argv[3]),ensure_ascii=True))" + $inventoryExit = Invoke-BoundedDriver -Arguments @( + "-B", "-c", $inventoryCode, $fixtureRoot, $executablePath, $acceptanceIdentifier + ) -LogPath $inventoryPath -TimeoutSeconds 15 + if ($inventoryExit -ne 0) { throw "Could not inspect acceptance-owned processes; see $inventoryPath" } + $snapshot = @(Get-Content -LiteralPath $inventoryPath -Raw | ConvertFrom-Json) + $survivors = @($snapshot | Where-Object { + ($_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -eq $executablePath) -or + ($_.CommandLine -and + $_.CommandLine.Contains("fake-lsp-server.mjs", [StringComparison]::OrdinalIgnoreCase) -and + $_.CommandLine.Contains($acceptanceIdentifier, [StringComparison]::OrdinalIgnoreCase)) + }) + # Retain kernel handles before terminating anything. A PID from a CIM + # snapshot is never sufficient authority to kill a later process. + $owners = [Collections.Generic.List[Diagnostics.Process]]::new() + $pending = [Collections.Generic.Queue[object]]::new() + foreach ($record in $survivors) { $pending.Enqueue($record) } + $seen = @{} + try { + while ($pending.Count -gt 0) { + $record = $pending.Dequeue() + $processId = [int]$record.ProcessId + if ($seen.ContainsKey($processId)) { continue } + $seen[$processId] = $true + $owner = $null + $retained = $false + try { + $owner = [Diagnostics.Process]::GetProcessById($processId) + $null = $owner.Handle + if ($owner.HasExited) { continue } + $created = [int64]$record.CreationTime + $actualCreated = $owner.StartTime.ToUniversalTime().ToFileTimeUtc() + if ($created -ne $actualCreated) { continue } + if (-not $record.ExecutablePath -or + [IO.Path]::GetFullPath($owner.MainModule.FileName) -ne [IO.Path]::GetFullPath($record.ExecutablePath)) { continue } + $owners.Add($owner) + $retained = $true + foreach ($child in $snapshot) { + if ($child.ParentProcessId -eq $processId -and $child.CreationTime -ge $record.CreationTime) { + $pending.Enqueue($child) + } + } + } catch [ArgumentException] { + # The original process exited before its handle was captured. + } catch [InvalidOperationException] { + # The retained process exited during inspection. + } finally { + if ($null -ne $owner -and -not $retained) { $owner.Dispose() } + } + } + foreach ($owner in $owners) { + if (-not $owner.HasExited) { $owner.Kill() } + } + $deadline = [Diagnostics.Stopwatch]::StartNew() + foreach ($owner in $owners) { + $remainingMs = [Math]::Max(0, 5000 - [int]$deadline.ElapsedMilliseconds) + if (-not $owner.WaitForExit($remainingMs)) { + throw "Acceptance-owned process survived cleanup: $($owner.Id)" + } + } + } finally { + foreach ($owner in $owners) { $owner.Dispose() } + } + return $survivors +} + +New-Item -ItemType Directory -Path $artifactParent -Force | Out-Null +New-Item -ItemType Directory -Path $stabilityRoot -Force | Out-Null +if (-not $SkipBuild -and -not $ResumeBuild) { + Reset-OwnedDirectory -Path $artifactRoot +} else { + New-Item -ItemType Directory -Path $artifactRoot -Force | Out-Null + if ($SkipBuild -and -not (Test-Path -LiteralPath $identityMarker -PathType Leaf)) { + throw "-SkipBuild requires the prior isolated build identity marker: $identityMarker" + } + if ($ResumeBuild -and -not (Test-Path -LiteralPath (Join-Path $artifactRoot "release-build.log") -PathType Leaf)) { + throw "-ResumeBuild requires evidence that this lane already attempted its formal Release build." + } + if ($SkipBuild) { + $priorIdentity = Get-Content -LiteralPath $identityMarker -Raw | ConvertFrom-Json + if ($priorIdentity.identifier -ne $acceptanceIdentifier -or + $priorIdentity.rustTarget -ne $RustTarget -or + $priorIdentity.gitRevision -ne (& git -C $root rev-parse HEAD).Trim() -or + -not (Test-Path -LiteralPath $customConfig -PathType Leaf) -or + $priorIdentity.configSha256 -ne (Get-FileHash -Algorithm SHA256 -LiteralPath $customConfig).Hash.ToLowerInvariant() -or + $priorIdentity.executableSha256 -ne (Get-FileHash -Algorithm SHA256 -LiteralPath $sourceExecutable).Hash.ToLowerInvariant()) { + throw "The prior executable, source revision or config no longer matches its isolated application identity marker." + } + Assert-TextWindowSourceInventory -Path $identityMarker -Identity + } +} + +$runImage = Join-Path $artifactRoot "run-image" +$packages = Join-Path $artifactRoot "packages" +$workspaces = Join-Path $artifactRoot "workspaces" +$runtimeRoot = Join-Path $artifactRoot "runtime" +$evidenceRoot = Join-Path $artifactRoot "evidence" +foreach ($directory in @($runImage, $packages, $workspaces, $runtimeRoot, $evidenceRoot)) { + Reset-OwnedDirectory -Path $directory +} + +if (-not $SkipBuild) { + @{ + '$schema' = "https://schema.tauri.app/config/2" + productName = "Lithe Host Acceptance" + identifier = $acceptanceIdentifier + } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $customConfig -Encoding utf8NoBOM +} + +Invoke-NativeChecked -Executable "node" -Arguments @("--check", (Join-Path $fixtureRoot "fake-lsp-server.mjs")) ` + -FailureMessage "The controlled language-server fixture has invalid JavaScript" +Invoke-NativeChecked -Executable "python" -Arguments @("-B", "-c", "import playwright,psutil") ` + -FailureMessage "Install acceptance dependencies with python -m pip install playwright==1.60.0 psutil==7.2.2" +Invoke-NativeChecked -Executable "python" -Arguments @( + "-B", + "-c", + "import ast,pathlib; [ast.parse(pathlib.Path(p).read_text(encoding='utf-8')) for p in __import__('sys').argv[1:]]", + (Join-Path $fixtureRoot "acceptance_driver.py"), + (Join-Path $fixtureRoot "acceptance_driver_test.py"), + (Join-Path $fixtureRoot "build_preflight_test.py"), + (Join-Path $fixtureRoot "marketplace_acceptance.py"), + (Join-Path $fixtureRoot "marketplace_acceptance_test.py"), + (Join-Path $fixtureRoot "lsp_lifecycle_acceptance.py"), + (Join-Path $fixtureRoot "lsp_lifecycle_acceptance_test.py"), + (Join-Path $fixtureRoot "marketplace_fixture.py"), + (Join-Path $fixtureRoot "generate_packages.py"), + (Join-Path $fixtureRoot "settings_functions_fixture.py"), + (Join-Path $fixtureRoot "settings_functions_acceptance.py"), + (Join-Path $fixtureRoot "settings_functions_acceptance_test.py"), + (Join-Path $fixtureRoot "text_window_fixture.py"), + (Join-Path $fixtureRoot "text_window_acceptance.py"), + (Join-Path $fixtureRoot "text_window_acceptance_test.py") +) -FailureMessage "A plugin host acceptance Python source file has invalid syntax" + +& (Join-Path $root ".agents/skills/write-stable-tests/scripts/verify-test-stability.ps1") -Platform windows +if ($LASTEXITCODE -ne 0) { throw "The Windows stable-test source guard failed." } +& (Join-Path $root "scripts/verify-windows-boundaries.ps1") +if ($LASTEXITCODE -ne 0) { throw "The Windows architecture boundary verification failed." } + +$driverUnitReport = Join-Path $stabilityRoot "plugin-host-acceptance-driver.json" +Invoke-NativeChecked -Executable "python" -Arguments @( + "-B", + (Join-Path $fixtureRoot "acceptance_driver_test.py"), + "--report", $driverUnitReport +) -FailureMessage "The acceptance process-owner unit checks failed" + +$preflightExit = Invoke-BoundedDriver -Arguments @( + "-B", (Join-Path $fixtureRoot "build_preflight_test.py"), + "--report", (Join-Path $stabilityRoot "plugin-build-preflight.json") +) -LogPath (Join-Path $artifactRoot "build-preflight-tests.log") -TimeoutSeconds 190 +if ($preflightExit -ne 0) { throw "The offline build preparation checks failed." } + +Invoke-NativeChecked -Executable "python" -Arguments @( + "-B", + (Join-Path $fixtureRoot "marketplace_acceptance_test.py"), + "--report", (Join-Path $stabilityRoot "plugin-marketplace-acceptance-driver.json") +) -FailureMessage "The marketplace fixture and locator unit checks failed" + +Invoke-NativeChecked -Executable "python" -Arguments @( + "-B", + (Join-Path $fixtureRoot "lsp_lifecycle_acceptance_test.py"), + "--report", (Join-Path $stabilityRoot "plugin-lsp-lifecycle-driver.json") +) -FailureMessage "The LSP lifecycle event and initialization gate unit checks failed" + +Invoke-NativeChecked -Executable "python" -Arguments @( + "-B", (Join-Path $fixtureRoot "settings_functions_acceptance_test.py"), + "--report", (Join-Path $stabilityRoot "plugin-settings-functions-driver.json") +) -FailureMessage "The settings/functions acceptance self-checks failed" +if ($TextWindow) { + Invoke-NativeChecked -Executable "python" -Arguments @( + "-B", (Join-Path $fixtureRoot "text_window_acceptance_test.py"), + "--report", (Join-Path $stabilityRoot "plugin-text-window-helpers.json") + ) -FailureMessage "The literal text-window helper checks failed" +} + +$zsh = Get-Command zsh -ErrorAction SilentlyContinue +$immutabilityReport = Join-Path $stabilityRoot "plugin-host-acceptance-source-guard.json" +if ($null -eq $zsh) { + Write-NormalizedReport -Path $immutabilityReport ` + -Name "original runtime bundle immutability source guard" ` + -Status "skipped" ` + -Details "zsh is unavailable on this Windows host; scripts/verify-runtime-bundle-immutability.sh remains pending and no substitute scan is labeled runtime acceptance." +} else { + $timer = [Diagnostics.Stopwatch]::StartNew() + # MSYS zsh does not normalize a native drive prefix in $0 before its :A + # expansion. A relative entry preserves the guard's root on SUBST drives. + Push-Location $root + try { + & $zsh.Source "./scripts/verify-runtime-bundle-immutability.sh" + } finally { + Pop-Location + } + if ($LASTEXITCODE -ne 0) { throw "The original runtime bundle immutability source guard failed." } + Write-NormalizedReport -Path $immutabilityReport ` + -Name "original runtime bundle immutability source guard" ` + -Status "passed" ` + -Details "scripts/verify-runtime-bundle-immutability.sh completed through zsh in $($timer.ElapsedMilliseconds) ms." +} + +if (-not $SkipBuild) { + # Only presence is needed here; avoid a full CIM inventory whose unrelated + # process properties can fail before the build even starts. + $heavyProcesses = @(Get-Process -Name cargo, rustc -ErrorAction SilentlyContinue) + if ($heavyProcesses.Count -gt 0) { + throw "Refusing to overlap the formal Release build with existing Cargo/Rust processes: $($heavyProcesses.Id -join ', ')" + } + + . (Join-Path $PSScriptRoot "windows-build-preflight.ps1") + Initialize-WindowsNativeBun -RepositoryRoot $root -NativeBunPath $NativeBunPath + + if (-not [string]::IsNullOrWhiteSpace($ResourceSource)) { + $resourceSourcePath = [IO.Path]::GetFullPath($ResourceSource) + if (-not (Test-Path -LiteralPath $resourceSourcePath -PathType Container)) { + throw "Resource source does not exist: $resourceSourcePath" + } + Invoke-NativeChecked -Executable "node" -Arguments @( + (Join-Path $root "scripts/reuse-worktree-resources.mjs"), + "--source", $resourceSourcePath + ) -FailureMessage "Worktree resource reuse failed" + } + + $previousCargoJobs = $env:CARGO_BUILD_JOBS + $previousToolchain = $env:RUSTUP_TOOLCHAIN + $previousConpty = $env:LITHE_REQUIRE_CONPTY + $transcriptStarted = $false + $sourceInventoryBefore = Join-Path $artifactRoot "text-window-source-before.json" + Save-TextWindowSourceInventory -Path $sourceInventoryBefore + try { + $env:CARGO_BUILD_JOBS = "2" + $env:RUSTUP_TOOLCHAIN = "stable-$RustTarget" + $env:LITHE_REQUIRE_CONPTY = "1" + $buildLogName = if ($ResumeBuild) { "release-resume.log" } else { "release-build.log" } + Start-Transcript -LiteralPath (Join-Path $artifactRoot $buildLogName) -Force | Out-Null + $transcriptStarted = $true + + # This is deliberately the only formal Release build invocation in the lane. + if (-not $ResumeBuild) { + & (Join-Path $root "scripts/build-windows.ps1") -Configuration Release -RustTarget $RustTarget -InstallTimeoutSeconds $InstallTimeoutSeconds + if ($LASTEXITCODE -ne 0) { throw "The formal Windows Release build failed." } + } else { + Push-Location (Join-Path $root "windows/tauri") + try { + Invoke-NativeChecked -Executable "bun" -Arguments @("run", "typecheck") ` + -FailureMessage "The resumed Windows frontend type check failed" + } finally { + Pop-Location + } + } + + $tauriArguments = @( + "tauri", "build", "--no-bundle", + "--config", "src-tauri/tauri.windows.conf.json", + "--config", "src-tauri/tauri.jdtls.conf.json", + "--config", $customConfig, + "--target", $RustTarget + ) + & (Join-Path $root "scripts/invoke-windows-tauri-build.ps1") ` + -TauriArguments $tauriArguments ` + -FailureMessage "The incremental host-acceptance identity link failed" + if ($LASTEXITCODE -ne 0) { throw "The incremental host-acceptance identity link failed." } + + if (-not (Test-Path -LiteralPath $sourceExecutable -PathType Leaf)) { + throw "The isolated Windows executable was not produced: $sourceExecutable" + } + $buildIdentity = @{ + identifier = $acceptanceIdentifier + rustTarget = $RustTarget + sourceExecutable = $sourceExecutable + gitRevision = (& git -C $root rev-parse HEAD).Trim() + configSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $customConfig).Hash.ToLowerInvariant() + executableSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $sourceExecutable).Hash.ToLowerInvariant() + } + Assert-TextWindowSourceInventory -Path $sourceInventoryBefore + $buildIdentity.sourceInventory = Get-Content -LiteralPath $sourceInventoryBefore -Raw | ConvertFrom-Json + $buildIdentity | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $identityMarker -Encoding utf8NoBOM + } finally { + if ($transcriptStarted) { Stop-Transcript | Out-Null } + $env:CARGO_BUILD_JOBS = $previousCargoJobs + $env:RUSTUP_TOOLCHAIN = $previousToolchain + $env:LITHE_REQUIRE_CONPTY = $previousConpty + } +} + +if (-not (Test-Path -LiteralPath $sourceExecutable -PathType Leaf)) { + throw "The custom-identity Release executable is missing: $sourceExecutable" +} + +$stagedExecutable = Join-Path $runImage "lithe-host-acceptance.exe" +Copy-Item -LiteralPath $sourceExecutable -Destination $stagedExecutable -Force +$resourceNames = @( + "extensions", + "OfficialPlugins", + "LanguageServers", + "helpers", + "conpty.dll", + "OpenConsole.exe", + "ConPTY-LICENSE.txt" +) +foreach ($resourceName in $resourceNames) { + $source = Join-Path $profileRoot $resourceName + if (Test-Path -LiteralPath $source) { + Copy-Item -LiteralPath $source -Destination (Join-Path $runImage $resourceName) -Recurse -Force + } +} + +# A resumed build may have been interrupted before build-windows.ps1 staged +# resources. Populate the run image from the same immutable build inputs. +$fallbackResources = [ordered]@{ + "extensions/bundled" = (Join-Path $root "windows/tauri/src/extensions/bundled") + "OfficialPlugins/PhpSupportContract" = (Join-Path $root "Plugins/win/Official/PhpSupportContract") + "LanguageServers/jdtls" = (Join-Path $artifactParent "jdtls") + "LanguageServers/jdk" = (Join-Path $artifactParent "jdk") + "helpers" = (Join-Path $root "windows/tauri/src-tauri/helpers") + "conpty.dll" = (Join-Path $artifactParent "conpty/conpty.dll") + "OpenConsole.exe" = (Join-Path $artifactParent "conpty/OpenConsole.exe") + "ConPTY-LICENSE.txt" = (Join-Path $artifactParent "conpty/ConPTY-LICENSE.txt") +} +foreach ($resource in $fallbackResources.GetEnumerator()) { + $destination = Join-Path $runImage $resource.Key + if (-not (Test-Path -LiteralPath $destination) -and (Test-Path -LiteralPath $resource.Value)) { + New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null + Copy-Item -LiteralPath $resource.Value -Destination $destination -Recurse -Force + } +} +foreach ($required in @( + $stagedExecutable, + (Join-Path $runImage "extensions/bundled"), + (Join-Path $runImage "OfficialPlugins"), + (Join-Path $runImage "LanguageServers/jdtls"), + (Join-Path $runImage "LanguageServers/jdk/bin/java.exe"), + (Join-Path $runImage "conpty.dll"), + (Join-Path $runImage "OpenConsole.exe") +)) { + if (-not (Test-Path -LiteralPath $required)) { throw "The staged run image is incomplete: $required" } +} + +Invoke-NativeChecked -Executable "python" -Arguments @( + "-B", + (Join-Path $fixtureRoot "generate_packages.py"), + "--output", $packages, + "--server", (Join-Path $fixtureRoot "fake-lsp-server.mjs") +) -FailureMessage "The deterministic plugin packages could not be generated" + +$workspaceOne = Join-Path $workspaces "one" +$workspaceTwo = Join-Path $workspaces "two" +New-Item -ItemType Directory -Path $workspaceOne, $workspaceTwo -Force | Out-Null +Set-Content -LiteralPath (Join-Path $workspaceOne "acceptance.litheaccept") -Value "workspace one" -Encoding utf8NoBOM +Set-Content -LiteralPath (Join-Path $workspaceOne "uninstall.litheaccept") -Value "uninstall workflow" -Encoding utf8NoBOM +Set-Content -LiteralPath (Join-Path $workspaceTwo "acceptance.litheaccept") -Value "workspace two" -Encoding utf8NoBOM + +$appLocalData = Join-Path ([Environment]::GetFolderPath("LocalApplicationData")) $acceptanceIdentifier +$appRoamingData = Join-Path ([Environment]::GetFolderPath("ApplicationData")) $acceptanceIdentifier +$webviewData = Join-Path $runtimeRoot "webview2" +$acceptanceReport = Join-Path $stabilityRoot "plugin-host-acceptance.json" +$driverLog = Join-Path $artifactRoot "acceptance-driver.log" +if ($TextWindow) { + $acceptanceReport = Join-Path $stabilityRoot "plugin-text-window-acceptance.json" + $driverLog = Join-Path $artifactRoot "text-window-driver.log" +} + +@{ + schemaVersion = 1 + identifier = $acceptanceIdentifier + gitRevision = (& git -C $root rev-parse HEAD).Trim() + rustTarget = $RustTarget + osArchitecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + processArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() + executable = $stagedExecutable + executableSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $stagedExecutable).Hash.ToLowerInvariant() + runImage = $runImage + resourceNames = @($resourceNames | Where-Object { Test-Path -LiteralPath (Join-Path $runImage $_) }) + appLocalData = $appLocalData + appRoamingData = $appRoamingData + webviewData = $webviewData +} | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $artifactRoot "metadata.json") -Encoding utf8NoBOM + +$driverExit = 1 +$cleanupFailure = $null +try { + $driverArguments = @( + "-B", (Join-Path $fixtureRoot "acceptance_driver.py"), + "--executable", $stagedExecutable, + "--run-image", $runImage, + "--artifact-root", $evidenceRoot, + "--report", $acceptanceReport, + "--app-local-data", $appLocalData, + "--app-roaming-data", $appRoamingData, + "--webview-data", $webviewData, + "--package-v1-a", (Join-Path $packages "host-acceptance-1.0.0-a.zip"), + "--package-v1-b", (Join-Path $packages "host-acceptance-1.0.0-b.zip"), + "--package-v2", (Join-Path $packages "host-acceptance-1.1.0.zip"), + "--workspace-one", $workspaceOne, + "--workspace-two", $workspaceTwo + ) + if ($Marketplace) { $driverArguments += "--marketplace" } + if ($LspLifecycle) { $driverArguments += "--lsp-lifecycle" } + if ($SettingsFunctions) { + $driverArguments += @( + "--settings-functions-fixture-root", ([IO.Path]::GetFullPath($SettingsFunctionsFixtureRoot)), + "--settings-functions-host-identity", $identityMarker + ) + } + if ($providedMarketplaceArguments.Count -eq 3) { + $driverArguments += @( + "--marketplace-fixture-root", ([IO.Path]::GetFullPath($MarketplaceFixtureRoot)), + "--marketplace-tls-cert", ([IO.Path]::GetFullPath($MarketplaceTlsCert)), + "--marketplace-tls-key", ([IO.Path]::GetFullPath($MarketplaceTlsKey)), + "--marketplace-listen-host", $MarketplaceListenHost + ) + } + if ($TextWindow) { + $driverArguments = @( + "-B", (Join-Path $fixtureRoot "text_window_acceptance.py"), + "--fixture-root", (Join-Path $artifactRoot ("text-window-fixture-" + [guid]::NewGuid().ToString("N"))), + "--host-identity", $identityMarker, + "--executable", $stagedExecutable, + "--run-image", $runImage, + "--artifact-root", (Join-Path $evidenceRoot "text-window"), + "--report", $acceptanceReport, + "--app-local-data", $appLocalData, + "--app-roaming-data", $appRoamingData, + "--webview-data", $webviewData + ) + } + $driverExit = Invoke-BoundedDriver -LogPath $driverLog -Arguments $driverArguments +} catch { + $driverFailure = $_.ToString() + Write-NormalizedReport -Path $acceptanceReport -Name "native acceptance driver deadline or startup" ` + -Status "failed" -Details $driverFailure + throw +} finally { + try { + $forced = @(Stop-OwnedAcceptanceProcesses -StagedExecutable $stagedExecutable) + if ($forced.Count -gt 0) { + $cleanupFailure = "The wrapper had to terminate acceptance-owned process IDs: $($forced.ProcessId -join ', ')" + } + } catch { + $cleanupFailure = $_.ToString() + } + foreach ($isolatedData in @($appLocalData, $appRoamingData)) { + $resolvedData = [IO.Path]::GetFullPath($isolatedData) + if ([IO.Path]::GetFileName($resolvedData) -ne $acceptanceIdentifier) { + $cleanupFailure = "Refusing cleanup of a non-acceptance data directory: $resolvedData" + continue + } + if (Test-Path -LiteralPath $resolvedData) { + try { + $dataEvidence = Join-Path $evidenceRoot ("wrapper-cleanup-" + [IO.Path]::GetFileName([IO.Path]::GetDirectoryName($resolvedData))) + New-Item -ItemType Directory -Path $dataEvidence -Force | Out-Null + foreach ($evidenceName in @("logs", "plugins", "plugin-state", "plugin-temp", "settings.json")) { + $evidenceSource = Join-Path $resolvedData $evidenceName + if (Test-Path -LiteralPath $evidenceSource) { + Copy-Item -LiteralPath $evidenceSource -Destination (Join-Path $dataEvidence $evidenceName) -Recurse -Force + } + } + Remove-Item -LiteralPath $resolvedData -Recurse -Force + } catch { + $cleanupFailure = $_.ToString() + } + } + } + & node (Join-Path $root ".agents/skills/write-stable-tests/scripts/generate-test-report.mjs") ` + --input-dir $stabilityRoot + if ($LASTEXITCODE -ne 0 -and $null -eq $cleanupFailure) { + $cleanupFailure = "Test stability report generation failed." + } +} + +if ($null -ne $cleanupFailure) { throw $cleanupFailure } +if ($driverExit -ne 0) { throw "The native Windows declarative plugin host acceptance failed (exit $driverExit)." } +Write-Output "Windows declarative plugin host acceptance passed; reports are in $stabilityRoot." diff --git a/scripts/test-windows-native-plugin-ui-browser.mjs b/scripts/test-windows-native-plugin-ui-browser.mjs new file mode 100644 index 0000000000..15f97f390f --- /dev/null +++ b/scripts/test-windows-native-plugin-ui-browser.mjs @@ -0,0 +1,1461 @@ +/** Controlled component regression, not product or ui:3 package admission. + * Example (no installation or download is performed by this runner): + * node scripts/test-windows-native-plugin-ui-browser.mjs --browser msedge + * --playwright-module /driver/package/index.mjs + * --report .artifacts/native-browser/edge.json + * Omit --playwright-module only when playwright is installed by repo configuration. + * --step-timeout-ms defaults to 60000; --timeout-ms defaults to 120000 + * (minimum 25000, including a 20000ms cleanup reserve). + * --mode workbench drives eight connected MainLayout workflow stages. + * --mode native-tree drives real browser keyboard/menu/focus regressions. + * --mode controls-input drives original native-controls input and disabled groups. + * --mode form-grid checks native-controls geometry and editing across 640px. + * --mode lifecycle --inject-failure worker-exit|worker-hang verifies fail-closed + * cleanup without Vite/browser dependencies; expected red reports are separate. + */ +import { + nativeWorkbenchRegression, + workbenchBoundaryPlugin, + WORKBENCH_STAGES, + WORKBENCH_OPTIMIZE_DEPS, +} from "./native-workbench-browser.mjs"; +import { spawn } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { writeTestReportArtifacts } from "../.agents/skills/write-stable-tests/scripts/generate-test-report.mjs"; + +const script = fileURLToPath(import.meta.url); +const root = path.resolve(path.dirname(script), ".."); +const app = path.join(root, "windows/tauri"); +const require = createRequire(path.join(app, "package.json")); +const started = performance.now(); +const textError = (error) => String(error?.stack ?? error); + +async function nativeControlsInputRegression(page, step) { + const snapshots = []; + const controls = await page.evaluate(() => window.nativeFormGridProbe.ui.controls); + const byId = (id) => controls.find((control) => control.id === id); + const field = (id) => + page.locator(".plugin-native-grid > *").nth(controls.findIndex((c) => c.id === id)); + // Base UI checkboxes also associate their hidden validation input with the + // field label. Drive the public checkbox rather than that implementation input. + const input = (id) => + byId(id).kind === "checkbox" + ? field(id).getByRole("checkbox", { name: byId(id).label, exact: true }) + : field(id).getByLabel(byId(id).label, { exact: true }); + const expected = Object.fromEntries(controls.map((c) => [c.id, c.value])); + let count = 0; + const errors = []; + const check = (condition, message) => { + if (!condition) throw new Error(message); + }; + const phase = async (name, action) => { + const begin = performance.now(); + const observation = await step(name, action); + snapshots.push({ name, durationMs: performance.now() - begin, observation }); + }; + const calls = () => page.evaluate(() => window.nativeFormGridProbe.calls.length); + const committed = async () => { + count += 1; + try { + await page.waitForFunction((n) => window.nativeFormGridProbe.calls.length >= n, count, { + timeout: 3000, + }); + // The owner disables the actual input through invoke and refresh. There + // is no aria-busy marker on this form; wait for that public ready boundary. + await page.waitForFunction( + () => { + const label = window.nativeFormGridProbe.ui.controls[0].label; + return [...document.querySelectorAll(".plugin-native-form input")].some( + (e) => e.getAttribute("aria-label") === label && !e.disabled, + ); + }, + null, + { timeout: 3000 }, + ); + } catch (error) { + const state = await page.evaluate(() => ({ + calls: window.nativeFormGridProbe.calls, + focus: document.activeElement?.outerHTML, + })); + throw new Error(`submission ${count}: ${textError(error)}; ${JSON.stringify(state)}`); + } + check((await calls()) === count, `submission ${count} duplicated port invocation`); + const observation = await page.evaluate(() => { + const call = window.nativeFormGridProbe.calls.at(-1); + return { + calls: window.nativeFormGridProbe.calls.length, + action: call.action, + values: call.context.values, + selectionUndefined: call.context.selectedID === undefined, + }; + }); + check(observation.action === "changed", "wrong action"); + check(observation.selectionUndefined, "typed context selection must be undefined"); + check( + JSON.stringify(observation.values) === JSON.stringify(expected), + `full current values mismatch: ${JSON.stringify(observation)}`, + ); + return observation; + }; + const type = async (id, value) => { + await input(id).click(); + await input(id).press("ControlOrMeta+A"); + await page.keyboard.type(value); + expected[id] = value; + check((await input(id).inputValue()) === value, `${id} draft not rendered`); + }; + try { + await phase("text selection, search draft/clear and multiline editor stay local", async () => { + await input("name").waitFor({ state: "visible" }); + const validationCount = await page.evaluate( + () => window.nativeFormGridProbe.validations.length, + ); + await input("name").click(); + await input("name").press("ControlOrMeta+A"); + check( + await input("name").evaluate( + (e) => e.selectionStart === 0 && e.selectionEnd === e.value.length, + ), + "native select-all did not select text", + ); + check((await calls()) === 0, "text selection invoked port"); + await type("search", "R23 search"); + await field("search").getByRole("button", { name: "Clear search", exact: true }).click(); + expected.search = ""; + check((await input("search").inputValue()) === "", "clear did not update draft"); + check( + await input("search").evaluate((e) => document.activeElement === e), + "clear lost input focus", + ); + await type("search", "R23 final query"); + await type("notes", "R23 first"); + await input("notes").press("Enter"); + await page.keyboard.type("second"); + expected.notes = "R23 first\nsecond"; + check( + (await input("notes").inputValue()) === expected.notes, + "editor Enter did not insert newline", + ); + check((await calls()) === 0, "draft/clear/editor called port"); + check( + (await page.evaluate(() => window.nativeFormGridProbe.validations.length)) === + validationCount, + "draft/selection called validation port", + ); + await input("search").press("Enter"); + return { ...(await committed()), searchEnterCommits: true }; + }); + await phase("select pointer option and keyboard option commit through owner", async () => { + await input("environment").click(); + check((await calls()) === count, "opening select invoked port"); + await page + .getByRole("option", { name: byId("environment").options[1].label, exact: true }) + .click(); + expected.environment = "test"; + await committed(); + await page.getByRole("listbox").waitFor({ state: "hidden", timeout: 3000 }); + await input("environment").press("Space"); + await page.getByRole("listbox").waitFor({ state: "visible", timeout: 3000 }); + // Upstream schedules initial option focus while the popup mounts. Send + // navigation only once the selected option owns the keyboard events. + await page.waitForFunction( + (label) => + document.activeElement?.getAttribute("role") === "option" && + document.activeElement.textContent.includes(label), + byId("environment").options[1].label, + { timeout: 3000 }, + ); + await page.keyboard.press("End"); + await page.waitForFunction( + (label) => + document.activeElement?.getAttribute("role") === "option" && + document.activeElement.textContent.includes(label), + byId("environment").options[2].label, + { timeout: 3000 }, + ); + await page.keyboard.press("Enter"); + expected.environment = "prod"; + return committed(); + }); + await phase("checkbox pointer and Space preserve boolean values", async () => { + await input("notify").click(); + expected.notify = false; + await committed(); + await input("notify").press("Space"); + expected.notify = true; + await committed(); + await input("offline").click(); + expected.offline = true; + check((await calls()) === count, "checkbox without onCommit invoked port"); + return { calls: count, offline: true }; + }); + await phase("segmented pointer and keyboard use real buttons", async () => { + const buttons = field("mode").locator('button[data-slot="toggle-group-item"]'); + await buttons.nth(0).click(); + expected.mode = "fast"; + await committed(); + await buttons.nth(0).press("End"); + check( + await buttons.nth(2).evaluate((e) => document.activeElement === e), + "segmented End did not move focus", + ); + check((await calls()) === count, "segmented focus navigation invoked port"); + await page.keyboard.press("Space"); + expected.mode = "debug"; + return committed(); + }); + await phase("stepper pointer and ArrowUp respect numeric type and bounds", async () => { + const increase = field("concurrency").getByRole("button", { + name: "Increase value", + exact: true, + }); + await increase.click(); + expected.concurrency = 3; + await committed(); + await input("concurrency").press("ArrowUp"); + expected.concurrency = 4; + await committed(); + for (let value = 5; value <= 8; value += 1) { + await increase.click(); + expected.concurrency = value; + await committed(); + } + check(await increase.isDisabled(), "maximum increment remains enabled"); + return { value: 8, maximumDisabled: true }; + }); + await phase( + "disabled native groups reject pointer and keyboard without losing values", + async () => { + const observations = []; + for (const control of controls.filter((c) => c.enabled === false)) { + // InfoTip belongs to the label; assert only the actual editing controls. + const editing = + control.kind === "segmented" + ? field(control.id).locator('button[data-slot="toggle-group-item"]') + : control.kind === "stepper" + ? field(control.id).locator( + 'input[data-setting-primary-control="true"],button[aria-label="Increase value"],button[aria-label="Decrease value"]', + ) + : input(control.id); + const expectedCount = + control.kind === "segmented" + ? control.options.length + : control.kind === "stepper" + ? 3 + : 1; + check( + (await editing.count()) === expectedCount, + `${control.id} missing editing controls`, + ); + if (control.kind === "segmented") + check( + await field(control.id) + .locator("fieldset") + .evaluate((e) => e.disabled), + "disabled segmented fieldset lost propagation", + ); + for (const node of await editing.all()) { + check(await node.isDisabled(), `${control.id} editing node is enabled`); + const box = await node.boundingBox(); + check(box !== null, `${control.id} missing editing node`); + await node.scrollIntoViewIfNeeded(); + const visible = await node.boundingBox(); + await page.mouse.click(2, 2); + await page.mouse.click(visible.x + visible.width / 2, visible.y + visible.height / 2); + check( + !(await node.evaluate((e) => document.activeElement === e)), + `${control.id} received pointer focus`, + ); + } + const info = field(control.id).getByRole("button", { + name: `Information for ${control.label}`, + exact: true, + }); + check(!(await info.isDisabled()), `${control.id} disabled its InfoTip`); + await info.click(); + const dialog = page.getByRole("dialog"); + await dialog.waitFor({ state: "visible", timeout: 3000 }); + check((await dialog.textContent()) === control.info, `${control.id} InfoTip changed`); + await info.click(); + await dialog.waitFor({ state: "detached", timeout: 3000 }); + // Start immediately before the editing controls in document order. + // One real Tab must leave the whole disabled group, including every + // segmented choice and both stepper buttons. + await info.press("Tab"); + check( + await field(control.id).evaluate( + (e) => + document.activeElement !== document.body && !e.contains(document.activeElement), + ), + `${control.id} received Tab focus or lost the keyboard focus`, + ); + observations.push({ + id: control.id, + disabled: true, + infoTipInteractive: true, + tabSkipped: true, + }); + } + check((await calls()) === count, "disabled interaction called port"); + return observations; + }, + ); + await phase( + "one final explicit submission contains every current and disabled field", + async () => { + await type("name", "R23 final name"); + await type("query", "R23 final draft"); + check((await calls()) === count, "final drafts invoked port"); + const label = await page.evaluate(() => window.nativeFormGridProbe.ui.buttons[0].label); + await page.getByRole("button", { name: label, exact: true }).click(); + const result = await committed(); + check((await calls()) === count, "final button duplicated submission"); + return { ...result, totalCalls: count }; + }, + ); + } catch (error) { + errors.push(error); + } finally { + try { + await step( + "dispose controls-input fixture", + () => + page.evaluate(() => { + window.nativeFormGridProbe.dispose(); + if (document.querySelector('[role="listbox"]')) throw new Error("Select portal leaked"); + if (document.querySelector('[role="dialog"]')) throw new Error("InfoTip portal leaked"); + }), + 5000, + ); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 1) + throw new AggregateError(errors, "Controls input failure and fixture cleanup failure"); + if (errors.length) throw errors[0]; + return { snapshots }; +} + +async function nativeFormGridRegression(page, step) { + const snapshots = []; + let primaryError; + const check = (condition, message) => { + if (!condition) throw new Error(message); + }; + const phase = async (name, action) => { + const begin = performance.now(); + const observation = await step(name, action); + snapshots.push({ name, durationMs: performance.now() - begin, observation }); + }; + const grid = page.locator(".plugin-native-grid"); + const labels = await page.evaluate(() => + window.nativeFormGridProbe.ui.controls.map((c) => c.label), + ); + const input = page.getByRole("textbox", { name: labels[0], exact: true }); + const draft = "R22 draft English"; + try { + await phase("12-column fixture ready; retain all control nodes", async () => { + await input.waitFor({ state: "visible" }); + await page.waitForFunction(() => document.fonts.status === "loaded"); + return grid.evaluate((element) => { + const controls = window.nativeFormGridProbe.ui.controls; + if ( + window.nativeFormGridProbe.ui.layout.columns !== 12 || + controls.some((c) => c.span !== 6) + ) + throw new Error("native-controls fixture layout changed"); + window.nativeFormGridNodes = [ + ...element.querySelectorAll("input,textarea,button,fieldset"), + ]; + window.nativeFormGridFields = [...element.children]; + if (element.children.length !== controls.length) throw new Error("missing declared fields"); + return { columns: 12, spans: controls.map((c) => c.span), fields: controls.length }; + }); + }); + await input.click(); + await input.press("ControlOrMeta+A"); + await page.keyboard.type(draft); + // Keep the 2600px viewport fixed and resize only the fixture container. + // This distinguishes a container query from a viewport media query. + for (const [name, width, tracks] of [ + ["wide", 900, 12], + ["below-640", 639, 1], + ["at-640", 640, 12], + ["wide-again", 900, 12], + ]) { + await phase(`${name}: span geometry, identity, draft and editing focus`, async () => { + try { + // Calibrate the real content width: a vertical scrollbar can appear + // during reflow, so padding alone is not the host-to-grid inset. + await page.waitForFunction( + (expected) => { + const host = document.getElementById("probe"); + const grid = host.querySelector(".plugin-native-grid"); + const delta = expected.width - grid.getBoundingClientRect().width; + if (Math.abs(delta) > 0.01) { + host.style.width = `${host.getBoundingClientRect().width + delta}px`; + return false; + } + return ( + getComputedStyle(grid).gridTemplateColumns.split(" ").length === expected.tracks + ); + }, + { width, tracks }, + { timeout: 3000, polling: "raf" }, + ); + } catch (error) { + const diagnostic = await grid.evaluate((element) => { + const form = element.closest(".plugin-native-form"); + return { + gridWidth: element.getBoundingClientRect().width, + hostWidth: document.getElementById("probe").getBoundingClientRect().width, + formWidth: form.getBoundingClientRect().width, + clientWidth: form.clientWidth, + scrollHeight: form.scrollHeight, + clientHeight: form.clientHeight, + template: getComputedStyle(element).gridTemplateColumns, + }; + }); + throw new Error( + `${name}: content width ${width}px / ${tracks} tracks did not settle within 3000ms; ${JSON.stringify(diagnostic)}; ${textError(error)}`, + ); + } + const result = await grid.evaluate( + (element, expected) => { + const fields = [...element.children]; + const nodes = [...element.querySelectorAll("input,textarea,button,fieldset")]; + if ( + fields.some((field, i) => field !== window.nativeFormGridFields[i]) || + nodes.length !== window.nativeFormGridNodes.length || + nodes.some((node, i) => node !== window.nativeFormGridNodes[i]) + ) + throw new Error("control DOM identity changed during reflow"); + const boxes = fields.map((field) => field.getBoundingClientRect().toJSON()); + const rect = element.getBoundingClientRect(); + const gap = parseFloat(getComputedStyle(element).columnGap); + const close = (a, b) => Math.abs(a - b) < 1; + if (!close(rect.width, expected.width)) + throw new Error("wrong container content width"); + boxes.forEach((box, index) => { + const columns = expected.tracks === 1 ? 1 : 2; + const expectedWidth = (rect.width - gap * (columns - 1)) / columns; + if ( + !close(box.width, expectedWidth) || + !close(box.x, rect.x + (index % columns) * (expectedWidth + gap)) || + (columns === 2 && index % 2 === 1 && !close(box.y, boxes[index - 1].y)) || + (index >= columns && + !close( + box.y, + Math.max( + ...boxes + .slice(index - (index % columns) - columns, index - (index % columns)) + .map((b) => b.bottom), + ) + gap, + )) + ) + throw new Error(`field ${index} violated span packing/top alignment`); + }); + const controls = window.nativeFormGridProbe.ui.controls; + fields.forEach((field, index) => { + const control = controls[index]; + if (control.enabled !== false) return; + if (control.kind === "segmented") { + const fieldset = field.querySelector("fieldset"); + const choices = [ + ...field.querySelectorAll('button[data-slot="toggle-group-item"]'), + ]; + if ( + !fieldset?.disabled || + choices.length !== control.options.length || + choices.some((choice) => !choice.matches(":disabled")) + ) + throw new Error(`${control.id}: fieldset/button disabled semantics lost`); + return; + } + const input = field.querySelector(`[aria-label=${JSON.stringify(control.label)}]`); + if (!(input?.matches(":disabled") || input?.getAttribute("aria-disabled") === "true")) + throw new Error(`${control.id}: disabled semantics lost`); + }); + return { + containerWidth: rect.width, + viewportWidth: innerWidth, + tracks: expected.tracks, + gap, + boxes, + }; + }, + { width, tracks }, + ); + check((await input.inputValue()) === draft, "local draft changed"); + check(await input.evaluate((el) => document.activeElement === el), "editing focus lost"); + check( + (await page.evaluate(() => window.nativeFormGridProbe.calls.length)) === 0, + "reflow/edit invoked port", + ); + return result; + }); + } + await phase( + "disabled field InfoTip and shared readOnly primitive retain semantics", + async () => { + const disabled = page.getByRole("textbox", { name: labels[5], exact: true }); + check(await disabled.isDisabled(), "disabled input enabled"); + const info = page.getByRole("button", { + name: `Information for ${labels[5]}`, + exact: true, + }); + await info.click(); + await page.getByRole("dialog").waitFor({ state: "visible" }); + check( + (await page.getByRole("dialog").textContent()) === + (await page.evaluate(() => window.nativeFormGridProbe.ui.controls[5].info)), + "InfoTip content changed", + ); + await info.click(); + await page.getByRole("dialog").waitFor({ state: "detached" }); + const readonly = page.getByRole("textbox", { name: "ReadOnly primitive", exact: true }); + check( + await readonly.evaluate((el) => el.readOnly && !el.disabled), + "readOnly primitive flags changed", + ); + await readonly.click(); + await readonly.press("ControlOrMeta+A"); + await page.keyboard.type("must not edit"); + check((await readonly.inputValue()) === "fixed query", "readOnly value changed"); + check( + await page + .locator("[data-readonly-probe]") + .getByRole("button", { name: "Clear search" }) + .isDisabled(), + "readOnly clear enabled", + ); + return { nativeReadOnly: "not declared by schema 1; shared SearchField only" }; + }, + ); + await phase("Return submits complete current values exactly once", async () => { + await input.click(); + await input.press("Enter"); + await page.waitForFunction( + () => + window.nativeFormGridProbe.calls.length === 1 && + !document.querySelector(".plugin-native-grid input").disabled, + ); + const calls = await page.evaluate(() => window.nativeFormGridProbe.calls); + const expected = await page.evaluate( + (value) => + Object.fromEntries( + window.nativeFormGridProbe.ui.controls.map((c) => [ + c.id, + c.id === "name" ? value : c.value, + ]), + ), + draft, + ); + // The typed port uses undefined for no selection; the native bridge is + // responsible for encoding that absence as provider JSON null. + check( + calls.length === 1 && + calls[0].action === "changed" && + calls[0].context.selectedID === undefined, + "wrong submission count/action/selection", + ); + check( + JSON.stringify(calls[0].context.values) === JSON.stringify(expected), + "submission omitted or changed field values", + ); + return { calls, expected }; + }); + return { snapshots }; + } catch (error) { + primaryError = error; + throw error; + } finally { + try { + await step( + "dispose form-grid fixture", + () => + page.evaluate(() => { + window.nativeFormGridProbe.dispose(); + delete window.nativeFormGridNodes; + delete window.nativeFormGridFields; + if (document.querySelector('[role="dialog"]')) throw new Error("InfoTip portal leaked"); + }), + 5000, + ); + } catch (error) { + throw new Error( + `${primaryError ? textError(primaryError) + "; " : ""}fixture cleanup: ${textError(error)}`, + ); + } + } +} + +// Playwright drives the browser's default keyboard path; fixture evaluation is +// limited to controlled port gates, DOM observation and deliberate scrolling. +async function nativeTreeRegression(page, step) { + const snapshots = []; + let primaryError; + const check = (condition, message) => { + if (!condition) throw new Error(message); + }; + const observe = (predicate) => page.waitForFunction(predicate); + // Context menus hide background content from the accessibility tree while + // open. Observe its mounted DOM without changing the public modal behavior; + // all keyboard and pointer actions below still use real browser input. + const tree = page.locator('[role="tree"][aria-label="Sample Explorer"]'); + const menu = page.getByRole("menu"); + const selected = () => tree.getAttribute("aria-activedescendant"); + const calls = () => page.evaluate(() => window.nativeTreeProbe.calls); + const phase = async (name, action) => { + const begin = performance.now(); + await step(name, action); + snapshots.push({ name, durationMs: performance.now() - begin }); + }; + const closed = () => menu.waitFor({ state: "detached" }); + const treeFocused = () => observe(() => document.activeElement?.getAttribute("role") === "tree"); + const open = async (key) => { + await tree.press(key); + await menu.waitFor({ state: "visible" }); + await observe(() => document.querySelector('[role="menu"]')?.contains(document.activeElement)); + // Base UI intentionally guards outside presses for 500ms after a + // controlled context-menu open. Advance its browser clock deterministically + // before testing dismissal, without sleeping or changing product handlers. + await page.clock.runFor(500); + check((await menu.count()) === 1, "expected exactly one public context menu"); + }; + try { + await phase("ready real tree and disabled no-selection action", async () => { + await tree.waitFor({ state: "visible" }); + await page.getByRole("treeitem", { name: "Branch", exact: false }).waitFor(); + const inspect = page.getByRole("button", { name: "Inspect", exact: true }); + check(await inspect.isDisabled(), "selection-required Inspect should be disabled"); + await inspect.focus(); + await page.keyboard.press("Enter"); + check((await calls()).length === 0, "disabled toolbar action ran"); + check((await tree.getByRole("treeitem").count()) < 100, "tree is not virtualized"); + await tree.focus(); + await tree.press("Home"); + await tree.press("ArrowRight"); + await tree.press("ArrowDown"); + await observe( + () => + document.querySelector('[role="treeitem"][aria-selected="true"]')?.textContent === + "Child row", + ); + }); + for (const key of ["Shift+F10", "ContextMenu"]) { + await phase(`${key} mounts offscreen selection and Escape returns to tree`, async () => { + await tree.evaluate((element) => { + element.scrollTop = 8000; + }); + await observe( + () => !document.querySelector('[role="tree"]')?.hasAttribute("aria-activedescendant"), + ); + await open(key); + const mounted = tree.locator('[role="treeitem"][aria-selected="true"]'); + check( + (await mounted.count()) === 1 && (await mounted.textContent()) === "Child row", + "selected child not remounted", + ); + const row = await tree.locator('[aria-selected="true"]').boundingBox(); + const viewport = await tree.boundingBox(); + const popup = await menu.boundingBox(); + check( + row && + viewport && + popup && + row.y >= viewport.y && + row.y + row.height <= viewport.y + viewport.height + 1, + "selected row not scrolled into viewport", + ); + check( + Math.abs(popup.y - row.y) < 4 && Math.abs(popup.x - (row.x + row.width)) < 4, + "public menu not anchored beside selected row", + ); + const before = await selected(); + for (const arrow of ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "End", "Home"]) { + await page.keyboard.press(arrow); + check((await selected()) === before, `${arrow} leaked into tree navigation`); + } + await page.keyboard.press("Escape"); + await closed(); + await treeFocused(); + }); + } + await phase("outside pointer dismisses menu; subsequent input retains focus", async () => { + await open("Shift+F10"); + // The public modal backdrop consumes the first pointer click to dismiss. + // Hit the real input coordinates, then edit after dismissal using another + // normal click; never force a click through the modal backdrop. + const input = page.locator('input[aria-label="Other setting"]'); + const bounds = await input.boundingBox(); + check(bounds, "outside input has no geometry"); + await step("outside pointer dismisses modal menu", () => + page.mouse.click(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2), + ); + await step("menu closes after outside click", closed, 10000); + await input.click(); + await observe(() => document.activeElement?.getAttribute("aria-label") === "Other setting"); + await page.keyboard.type("outside draft"); + check( + (await page.getByRole("textbox", { name: "Other setting" }).inputValue()) === + "outside draft", + "outside editing lost focus", + ); + }); + await phase("Inspect exactly once with child target; busy menu blocks activation", async () => { + await page.evaluate(() => window.nativeTreeProbe.configure({ hold: true })); + await tree.focus(); + await open("ContextMenu"); + await page.keyboard.press("End"); + await page.keyboard.down("Enter"); + await page.keyboard.down("Enter"); + await page.keyboard.up("Enter"); + await closed(); + await observe( + () => window.nativeTreeProbe.calls.length === 1 && window.nativeTreeProbe.pending() === 1, + ); + check((await calls())[0].context.selectedID === "child", "Inspect target changed"); + check((await calls())[0].action === "inspect", "wrong action invoked"); + await tree.focus(); + await open("Shift+F10"); + check( + (await menu.getByRole("menuitem", { name: "Inspect" }).getAttribute("aria-disabled")) === + "true", + "busy Inspect is enabled", + ); + await page.keyboard.press("End"); + await page.keyboard.press("Enter"); + check((await calls()).length === 1, "busy action executed"); + await page.keyboard.press("Escape"); + await closed(); + await treeFocused(); + await page.evaluate(() => window.nativeTreeProbe.release()); + await observe( + () => + window.nativeTreeProbe.pending() === 0 && + !document.querySelector('button[aria-label="Inspect"]')?.disabled, + ); + }); + await phase("action dialog retains its new focus owner", async () => { + await page.evaluate(() => window.nativeTreeProbe.configure({ dialog: true })); + await open("ContextMenu"); + await page.keyboard.press("End"); + await page.keyboard.press("Enter"); + await closed(); + await page.getByRole("dialog").waitFor({ state: "visible" }); + await observe(() => document.activeElement?.getAttribute("aria-label") === "Action input"); + await page.keyboard.type("action draft"); + check( + (await page.getByRole("textbox", { name: "Action input" }).inputValue()) === "action draft", + "menu stole dialog input focus", + ); + const invoked = await calls(); + check( + invoked.length === 2 && invoked.every((call) => call.context.selectedID === "child"), + "dialog action target/count changed", + ); + await page.evaluate(() => window.nativeTreeProbe.closeDialog()); + await page.getByRole("dialog").waitFor({ state: "detached" }); + }); + await phase("virtual row unmount closes menu without reopening", async () => { + await tree.focus(); + await open("Shift+F10"); + await tree.evaluate((element) => { + element.scrollTop = 8000; + }); + await closed(); + await observe( + () => !document.querySelector('[role="tree"]')?.hasAttribute("aria-activedescendant"), + ); + await tree.evaluate((element) => { + element.scrollTop = 0; + }); + await tree.getByRole("treeitem", { name: "Child row", exact: true }).waitFor(); + check((await menu.count()) === 0, "menu reopened after row remount"); + }); + const keyboardEvents = await page.evaluate(() => window.nativeTreeKeyboardEvents); + check( + ["F10", "ContextMenu"].every((key) => + keyboardEvents.some((event) => event.key === key && event.isTrusted), + ), + "both menu triggers must traverse trusted browser keyboard events", + ); + return { + snapshots, + calls: await calls(), + keyboardEvents, + input: "Playwright trusted keyboard; no synthetic click fallback", + }; + } catch (error) { + const diagnostic = await page + .evaluate(() => ({ + active: document.activeElement?.outerHTML, + menus: [...document.querySelectorAll('[role="menu"]')].map((element) => element.outerHTML), + input: document.querySelector('input[aria-label="Other setting"]')?.outerHTML, + })) + .catch(() => null); + primaryError = new Error(`${textError(error)}; DOM diagnostic: ${JSON.stringify(diagnostic)}`); + throw primaryError; + } finally { + try { + await step("dispose tree fixture", () => + page.evaluate(() => window.nativeTreeProbe.dispose()), + ); + await closed(); + await page.getByRole("dialog").waitFor({ state: "detached" }); + check( + (await page.evaluate(() => window.nativeTreeProbe.pending())) === 0, + "controlled port gate leaked", + ); + } catch (cleanupError) { + throw new Error( + `${primaryError ? textError(primaryError) + "; " : ""}fixture cleanup: ${textError(cleanupError)}`, + ); + } + } +} + +async function worker(options) { + if (["worker-exit", "worker-hang", "supervisor-exit"].includes(options.injectFailure)) { + const descendant = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + stdio: "ignore", + windowsHide: true, + }); + await new Promise((resolve, reject) => { + descendant.once("spawn", resolve); + descendant.once("error", reject); + }); + await writeFile( + options.injectionMarker, + JSON.stringify({ workerPid: process.pid, descendantPid: descendant.pid }), + ); + if (options.injectFailure === "worker-exit") process.exit(17); + // The independent Job Object owner, not this deliberately stuck worker, + // must terminate both processes within the caller's deadline. + await new Promise(() => {}); + } + let server, context, page; + const evidence = { pageErrors: [], windowErrors: [], steps: [], cleanup: {} }; + const step = async (name, action, ms = options.stepMs) => { + const begin = performance.now(); + let timer; + try { + return await Promise.race([ + Promise.resolve().then(action), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${name}: deadline ${ms}ms exceeded`)), ms); + }), + ]); + } finally { + clearTimeout(timer); + evidence.steps.push({ name, durationMs: performance.now() - begin }); + } + }; + try { + const fixtureFile = + options.mode === "workbench" + ? "native-workbench.browser-test.tsx" + : options.mode === "native-tree" + ? "native-tree.browser-test.tsx" + : ["form-grid", "controls-input"].includes(options.mode) + ? "native-form-grid.browser-test.tsx" + : "native-panel.browser-test.tsx"; + const { createServer } = await step( + "load Vite", + () => import(pathToFileURL(require.resolve("vite")).href), + ); + const { chromium } = await step( + "load Playwright", + () => + import( + options.module + ? pathToFileURL(path.resolve(options.module)).href + : pathToFileURL(require.resolve("playwright")).href + ), + ); + server = await step("create Vite", () => + createServer({ + root: app, + configFile: path.join(app, "vite.config.ts"), + cacheDir: path.join(options.scratch, "cache"), + optimizeDeps: { + ...(options.mode === "workbench" ? WORKBENCH_OPTIMIZE_DEPS : {}), + entries: [ + `src/features/plugins/native-ui/${options.mode === "workbench" ? "native-main-layout.test-boundaries.tsx" : fixtureFile}`, + ], + }, + server: { port: 0, host: "127.0.0.1", open: false }, + plugins: [ + ...(options.mode === "workbench" ? [workbenchBoundaryPlugin(app)] : []), + { + name: "native-panel-browser-regression", + configureServer(vite) { + vite.middlewares.use("/native-panel-test.html", async (_request, response, next) => { + try { + const html = await vite.transformIndexHtml( + "/native-panel-test.html", + `
    `, + ); + response.setHeader("Content-Type", "text/html"); + response.end(html); + } catch (error) { + next(error); + } + }); + }, + }, + ], + }), + ); + await step("listen Vite", () => server.listen()); + context = await step("launch browser", () => + chromium.launchPersistentContext(path.join(options.scratch, "profile"), { + ...(options.browser === "chromium" ? {} : { channel: options.browser }), + headless: true, + timeout: options.stepMs, + viewport: { width: 2600, height: 1100 }, + }), + ); + evidence.browserVersion = context.browser()?.version(); + page = await step("create page", () => context.newPage()); + page.setDefaultTimeout(options.stepMs); + page.on("pageerror", (error) => evidence.pageErrors.push(textError(error))); + if (options.mode === "workbench") { + evidence.consoleErrors = []; + page.on("console", (message) => { + if (message.type() === "error") + evidence.consoleErrors.push({ text: message.text(), location: message.location() }); + }); + } + // Installed before navigation: includes ResizeObserver errors and resource errors. + await step("install window.error listener", () => + page.addInitScript(() => { + window.nativePanelWindowErrors = []; + window.nativeTreeKeyboardEvents = []; + document.addEventListener("keydown", (event) => { + window.nativeTreeKeyboardEvents.push({ + key: event.key, + shiftKey: event.shiftKey, + isTrusted: event.isTrusted, + defaultPrevented: event.defaultPrevented, + }); + }); + window.addEventListener( + "error", + (event) => + window.nativePanelWindowErrors.push( + event.message || + `Resource error: ${event.target?.src || event.target?.href || event.target?.tagName}`, + ), + true, + ); + }), + ); + await step("navigate", () => + page.goto(`http://127.0.0.1:${server.httpServer.address().port}/native-panel-test.html`, { + timeout: options.stepMs, + waitUntil: "domcontentloaded", + }), + ); + await step("bootstrap", () => + page.waitForFunction(() => typeof window.runNativePanelTest === "function", null, { + timeout: options.stepMs, + }), + ); + await step("load resources", () => + page.waitForLoadState("networkidle", { timeout: options.stepMs }), + ); + if (options.injectFailure === "page") + await step("inject page failure", () => + page.evaluate(() => { + window.dispatchEvent(new ErrorEvent("error", { message: "controlled runner failure" })); + }), + ); + if (options.mode === "native-tree") + await step("install controlled browser clock", () => page.clock.install()); + evidence.component = + options.mode === "workbench" + ? await nativeWorkbenchRegression(page, step) + : options.mode === "native-tree" + ? await nativeTreeRegression(page, step) + : ["form-grid", "controls-input"].includes(options.mode) + ? options.mode === "controls-input" + ? await nativeControlsInputRegression(page, step) + : await nativeFormGridRegression(page, step) + : await step("six-stage component regression", () => + page.evaluate(() => window.runNativePanelTest()), + ); + evidence.windowErrors = await step("collect window errors", () => + page.evaluate(() => window.nativePanelWindowErrors), + ); + evidence.bootstrapIPC = await step("collect mock IPC", () => + page.evaluate(() => window.bootstrapIPC), + ); + if ( + evidence.windowErrors.length || + evidence.pageErrors.length || + evidence.consoleErrors?.length + ) + throw new Error( + JSON.stringify({ + windowErrors: evidence.windowErrors, + pageErrors: evidence.pageErrors, + consoleErrors: evidence.consoleErrors, + }), + ); + } catch (error) { + evidence.error = textError(error); + if (error.workbenchEvidence) evidence.component = error.workbenchEvidence; + if (page) { + try { + evidence.windowErrors = await step( + "collect window errors after failure", + () => page.evaluate(() => window.nativePanelWindowErrors ?? []), + 2000, + ); + } catch (observationError) { + evidence.error += `; window.error collection: ${textError(observationError)}`; + } + } + } finally { + for (const [name, close] of [ + ["browser", () => context?.close()], + ["Vite", () => server?.close()], + ]) { + try { + await step(`close ${name}`, close, 5000); + evidence.cleanup[name] = true; + } catch (error) { + (evidence.cleanup.errors ??= []).push(textError(error)); + } + } + evidence.cleanup.serverListening = server?.httpServer?.listening ?? false; + } + await writeFile(options.evidenceFile, JSON.stringify(evidence)); + // The independent supervisor always terminates and proves the entire job + // empty, including drivers left behind after this worker exits unexpectedly. + process.exit(evidence.error || evidence.cleanup.errors?.length ? 1 : 0); +} + +async function main() { + const options = { + mode: "action-group", + browser: "msedge", + stepMs: 60000, + overallMs: 120000, + report: path.join(root, ".artifacts/test-stability/native-panel-browser.json"), + }; + let child, timer, hardTimer, scratch; + const result = { + scope: "controlled browser component; no product or ui:3 admission", + command: process.argv, + exitCode: 1, + }; + try { + const args = process.argv.slice(2); + for (let index = 0; index < args.length; index++) { + const flag = args[index]; + const value = args[++index]; + if (!value) throw new Error(`Missing value for ${flag}`); + if (flag === "--browser") options.browser = value; + else if (flag === "--mode") options.mode = value; + else if (flag === "--playwright-module") options.module = value; + else if (flag === "--report") options.report = path.resolve(value); + else if (flag === "--step-timeout-ms") options.stepMs = Number(value); + else if (flag === "--timeout-ms") options.overallMs = Number(value); + else if ( + flag === "--inject-failure" && + ["page", "worker-exit", "worker-hang", "supervisor-exit"].includes(value) + ) + options.injectFailure = value; + else throw new Error(`Unknown argument: ${flag}`); + } + if (process.platform !== "win32") + throw new Error("This runner requires Windows process-tree cleanup"); + if ( + ![ + "action-group", + "native-tree", + "form-grid", + "controls-input", + "workbench", + "lifecycle", + ].includes(options.mode) + ) + throw new Error( + "mode must be action-group, native-tree, form-grid, controls-input, workbench or lifecycle", + ); + if ( + options.mode === "lifecycle" && + !["worker-exit", "worker-hang"].includes(options.injectFailure) + ) + throw new Error("lifecycle requires --inject-failure worker-exit or worker-hang"); + if (!["msedge", "chrome", "chromium"].includes(options.browser)) + throw new Error("browser must be msedge, chrome or chromium"); + for (const ms of [options.stepMs, options.overallMs]) + if (!Number.isSafeInteger(ms) || ms < 1 || ms > 600000) + throw new Error("Deadlines must be integers in 1..600000ms"); + if (!options.report.endsWith(".json")) throw new Error("--report must end in .json"); + if (options.overallMs < 25000) + throw new Error("--timeout-ms must be at least 25000ms, including cleanup reserve"); + hardTimer = setTimeout( + () => { + // This watchdog also covers a stalled finalizer or a failed supervisor + // termination. The supervisor owns a non-inherited KILL_ON_JOB_CLOSE + // handle and an earlier independent worker deadline. + try { + if (child && child.exitCode === null && child.signalCode === null) child.kill(); + } catch (error) { + result.supervisorTerminationError = textError(error); + } + result.exitCode = 1; + result.error = `Overall deadline ${options.overallMs}ms exceeded including cleanup`; + result.cleanup ??= {}; + result.cleanup.ownershipCleanupUnproven = true; + result.cleanup.retainedScratch = scratch; + result.durationMs = performance.now() - started; + try { + writeFileSync( + options.report, + JSON.stringify( + { + schemaVersion: 1, + runner: "native-panel controlled browser", + warnMs: 30000, + maxMs: options.overallMs, + result, + tests: [ + { + name: "browser runner overall watchdog", + suite: "native-panel.browser-test.tsx", + status: "timeout", + durationMs: result.durationMs, + message: result.error, + }, + ], + }, + null, + 2, + ), + ); + writeTestReportArtifacts(options.report); + } finally { + process.exit(1); + } + }, + Math.max(1, options.overallMs - (performance.now() - started)), + ); + await mkdir(path.dirname(options.report), { recursive: true }); + await writeFile( + options.report, + JSON.stringify({ + schemaVersion: 1, + runner: "native-panel controlled browser", + warnMs: 30000, + maxMs: options.overallMs, + tests: [ + { + name: "browser runner pending completion", + suite: "native-panel.browser-test.tsx", + status: "incomplete", + durationMs: 0, + }, + ], + }), + ); + writeTestReportArtifacts(options.report); + await mkdir(path.join(root, ".artifacts"), { recursive: true }); + scratch = await mkdtemp(path.join(root, ".artifacts/native-browser-")); + options.scratch = scratch; + options.evidenceFile = path.join(scratch, "worker-evidence.json"); + options.injectionMarker = path.join(scratch, "injection.json"); + const jobResult = path.join(scratch, "job-result.json"); + const executionMs = Math.max( + 1, + Math.floor(options.overallMs - 20000 - (performance.now() - started)), + ); + const supervisorArgs = [ + "-B", + path.join(root, "scripts/plugin-host-acceptance/native_browser_supervisor.py"), + "--timeout-ms", + String(executionMs), + "--result", + jobResult, + "--log", + path.join(scratch, "worker.log"), + ...(options.injectFailure === "supervisor-exit" + ? ["--force-owner-exit-on", options.injectionMarker] + : []), + "--", + process.execPath, + script, + "--worker", + JSON.stringify(options), + ]; + const supervisorExit = await new Promise((resolve, reject) => { + child = spawn("python", supervisorArgs, { + stdio: ["ignore", "inherit", "inherit"], + windowsHide: true, + }); + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + // The helper's worker budget ends 20s before overall. Its existing job + // cleanup has 7s; reserve the remaining time for parent cleanup/reporting. + timer = setTimeout( + () => reject(new Error("Job supervisor exceeded its cleanup deadline")), + Math.max(1, options.overallMs - 10000 - (performance.now() - started)), + ); + }); + result.supervisorExit = supervisorExit; + try { + result.job = JSON.parse(await readFile(jobResult, "utf8")); + } catch (error) { + throw new Error(`Job cleanup proof unavailable: ${textError(error)}`); + } + if ( + result.job.cleanup?.jobTerminated !== true || + result.job.cleanup?.activeProcesses !== 0 || + result.job.cleanupError + ) { + throw new Error("Job cleanup did not prove zero active processes"); + } + try { + result.evidence = JSON.parse(await readFile(options.evidenceFile, "utf8")); + } catch (error) { + throw new Error( + `Worker evidence unavailable after ${result.job.exitCode}: ${textError(error)}`, + ); + } + const evidence = result.evidence; + if ( + evidence.component?.snapshots?.length !== + (options.mode === "workbench" + ? WORKBENCH_STAGES + : ["native-tree", "form-grid", "controls-input"].includes(options.mode) + ? 7 + : 6) && + !evidence.error + ) + throw new Error("Expected all component regression stages"); + if (evidence.error) throw new Error(evidence.error); + if (evidence.cleanup.errors?.length || evidence.cleanup.serverListening) + throw new Error("Worker cleanup failed"); + if (supervisorExit.code !== 0 || result.job.status !== "passed") + throw new Error(`Job supervisor failed: ${result.job.error ?? supervisorExit.code}`); + result.exitCode = 0; + } catch (error) { + result.error = textError(error); + } finally { + clearTimeout(timer); + result.cleanup = {}; + if (child && child.exitCode === null && child.signalCode === null) { + // Terminating this exact direct child closes its native Job handle. We + // still fail closed without a published zero-member proof. + result.cleanup.supervisorTerminationRequested = child.kill(); + result.exitCode = 1; + result.cleanup.supervisorExited = await new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) return resolve(true); + const deadline = setTimeout(() => resolve(false), 5000); + child.once("exit", () => { + clearTimeout(deadline); + resolve(true); + }); + }); + if (!result.cleanup.supervisorExited) child.unref(); + } + const ownershipProven = + result.job?.cleanup?.jobTerminated === true && + result.job?.cleanup?.activeProcesses === 0 && + !result.job?.cleanupError; + result.cleanup.ownershipProven = ownershipProven; + if (options.injectFailure && options.injectionMarker) { + try { + result.lifecycleInjection = JSON.parse(await readFile(options.injectionMarker, "utf8")); + } catch { + /* No injection marker means the controlled worker never started. */ + } + } + if (scratch && !ownershipProven) { + result.cleanup.retainedScratch = scratch; + result.exitCode = 1; + } + if (scratch && ownershipProven) { + let cleanupTimer; + try { + result.workerLog = options.report.replace(/\.json$/, ".worker.log"); + await writeFile(result.workerLog, await readFile(path.join(scratch, "worker.log"))); + await Promise.race([ + rm(scratch, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }), + new Promise((_, reject) => { + cleanupTimer = setTimeout( + () => reject(new Error("Profile/cache cleanup exceeded 5000ms")), + 5000, + ); + }), + ]); + result.cleanup.profileAndCacheRemoved = true; + } catch (error) { + result.cleanup.error = textError(error); + result.cleanup.retainedScratch = scratch; + result.exitCode = 1; + } finally { + clearTimeout(cleanupTimer); + } + } + result.durationMs = performance.now() - started; + if (result.durationMs >= options.overallMs) { + result.error ??= `Overall deadline ${options.overallMs}ms exceeded including cleanup`; + result.exitCode = 1; + } + let report = { + schemaVersion: 1, + runner: "native-panel controlled browser", + warnMs: 30000, + maxMs: options.overallMs, + processDurationMs: result.durationMs, + result, + tests: [ + { + name: `${options.mode} browser regression`, + suite: + options.mode === "workbench" + ? "native-workbench.browser-test.tsx" + : options.mode === "native-tree" + ? "native-tree.browser-test.tsx" + : ["form-grid", "controls-input"].includes(options.mode) + ? "native-form-grid.browser-test.tsx" + : "native-panel.browser-test.tsx", + status: result.exitCode === 0 ? "passed" : "failed", + durationMs: result.durationMs, + message: result.error ?? result.cleanup.error, + }, + ...(result.evidence?.component?.snapshots ?? []).map((stage) => ({ + name: stage.name, + suite: + options.mode === "workbench" + ? "native-workbench workflow" + : options.mode === "native-tree" + ? "native-tree keyboard" + : ["form-grid", "controls-input"].includes(options.mode) + ? options.mode === "controls-input" + ? "native-controls input" + : "native-form grid" + : "native-panel geometry", + status: "passed", + durationMs: stage.durationMs, + })), + ], + }; + if (options.mode === "lifecycle") { + // Preserve the expected red report separately. Only positive assertions + // about that failure and its cleanup enter the lifecycle pass totals. + const expectedCode = options.injectFailure === "worker-exit" ? 17 : 124; + report.expectedFailure = { injection: options.injectFailure, workerExitCode: expectedCode }; + const expectedReport = path.join( + path.dirname(options.report), + "expected-failures", + path.basename(options.report), + ); + await mkdir(path.dirname(expectedReport), { recursive: true }); + await writeFile(expectedReport, JSON.stringify(report, null, 2)); + writeTestReportArtifacts(expectedReport); + const assertions = { + "runner fails closed": result.exitCode === 1 && !!result.error, + "injected worker and descendant started": + Number.isInteger(result.lifecycleInjection?.workerPid) && + Number.isInteger(result.lifecycleInjection?.descendantPid) && + result.lifecycleInjection.workerPid !== result.lifecycleInjection.descendantPid, + "expected worker failure observed": + result.job?.exitCode === expectedCode && + result.job?.status === "failed" && + result.supervisorExit?.code === 1, + "job proves owned descendants terminated": + result.cleanup.ownershipProven === true && result.job?.cleanup?.totalProcesses >= 3, + "owned profile and cache removed": + result.cleanup.profileAndCacheRemoved === true && !result.cleanup.retainedScratch, + "total deadline respected": result.durationMs < options.overallMs, + }; + report = { + schemaVersion: 1, + runner: `native browser lifecycle ${options.injectFailure}`, + warnMs: 30000, + maxMs: options.overallMs, + processDurationMs: result.durationMs, + expectedFailureReport: expectedReport, + result, + assertions, + tests: [ + { + name: "bounded injected worker lifecycle", + suite: `native browser lifecycle ${options.injectFailure}`, + status: Object.values(assertions).every(Boolean) ? "passed" : "failed", + durationMs: result.durationMs, + message: Object.entries(assertions) + .filter(([, passed]) => !passed) + .map(([name]) => name) + .join("; "), + }, + ], + }; + result.exitCode = Object.values(assertions).every(Boolean) ? 0 : 1; + } + await mkdir(path.dirname(options.report), { recursive: true }); + await writeFile(options.report, JSON.stringify(report, null, 2)); + writeTestReportArtifacts(options.report); + console.log(JSON.stringify(result, null, 2)); + clearTimeout(hardTimer); + // An abandoned timed-out promise must not keep a failed CLI alive. + process.exit(result.exitCode); + } +} +if (process.argv[2] === "--worker") await worker(JSON.parse(process.argv[3])); +else await main(); diff --git a/scripts/test-windows-plugin-vcs-core.mjs b/scripts/test-windows-plugin-vcs-core.mjs new file mode 100644 index 0000000000..38f118b594 --- /dev/null +++ b/scripts/test-windows-plugin-vcs-core.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node + +// Explicit native integration with a controlled Provider over real Core stdio. +// This does not verify an installed production plugin or the system SVN client. +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArguments, run } from "../.agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs"; +import { runProcess } from "../.agents/skills/write-stable-tests/scripts/test-timing-lib.mjs"; + +const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const TEST_NAME = "plugin_host::vcs::session::core_tests::real_core_stdio_optional_writes_use_only_the_frozen_typed_arguments"; +// Four operations each bound request work to 5s and owner cleanup to 13s. +// The case watchdog includes those paths plus fixture setup; the suite stays bounded. +const TEST_BUDGET_MS = 90000; + +async function runVcsCoreProcess(request) { + if (request.args.includes("--list")) { + const listed = await runProcess(request); + return { + ...listed, + stdout: listed.stdout + .split(/\r?\n/) + .filter((line) => line === `${TEST_NAME}: test`) + .join("\n"), + }; + } + if (request.args[0] === "--exact" && request.args[1] === TEST_NAME) { + return runProcess({ + ...request, + args: [...request.args, "--ignored"], + env: { ...(request.env ?? process.env), LITHE_VCS_TEST_NODE: process.execPath }, + }); + } + return runProcess(request); +} + +async function main() { + const arguments_ = process.argv.slice(2); + if ( + arguments_.length !== 0 && + (arguments_.length !== 2 || + arguments_[0] !== "--report" || + !arguments_[1] || + arguments_[1].startsWith("--") || + !arguments_[1].toLowerCase().endsWith(".json")) + ) { + throw new Error("Usage: node scripts/test-windows-plugin-vcs-core.mjs [--report ]"); + } + const options = parseArguments([ + "--manifest", path.join(REPOSITORY_ROOT, "windows/tauri/src-tauri/Cargo.toml"), + "--report", path.join(REPOSITORY_ROOT, ".artifacts/test-stability/windows-plugin-vcs-core.json"), + "--test-budget", `${TEST_NAME}=${TEST_BUDGET_MS}`, + ...arguments_, + ]); + // Keep the shared runner's 20-minute suite/build budgets, report formats, + // per-case timing and process-tree watchdog. Only this case opts into ignored. + await run(options, { runProcessImpl: runVcsCoreProcess }); + const report = JSON.parse(readFileSync(options.report, "utf8")); + if ( + report.tests?.length !== 1 || + report.tests[0].name !== TEST_NAME || + report.tests[0].status !== "passed" || + report.tests[0].maxMs !== TEST_BUDGET_MS + ) { + throw new Error("The real Core VCS stdio integration must report exactly one passed case; skipped tests do not verify it."); + } +} + +try { + await main(); +} catch (error) { + console.error(`Windows plugin VCS Core integration failed: ${error.message}`); + process.exitCode = 1; +} diff --git a/scripts/verify-app-language-localization.mjs b/scripts/verify-app-language-localization.mjs index 9f9d44cce9..89c644a5c2 100755 --- a/scripts/verify-app-language-localization.mjs +++ b/scripts/verify-app-language-localization.mjs @@ -5,9 +5,11 @@ // (any `bundle:` other than `.main` / `.appLanguage`, for example a test or Agent language bundle). import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; -const root = new URL("..", import.meta.url).pathname; +const root = fileURLToPath(new URL("..", import.meta.url)); const sourceRoot = join(root, "macos/Sources"); +const localizationImplementation = join(sourceRoot, "LitheModuleAPI/Localization/InterfaceLanguage.swift"); // Returns the index just past the string literal that starts at `i`, skipping `\( … )` interpolation. function skipString(s, i) { @@ -60,7 +62,7 @@ function swiftFiles(dir) { const violations = []; for (const file of swiftFiles(sourceRoot)) { - if (file.endsWith("Localization/InterfaceLanguage.swift")) continue; // the L10n implementation itself + if (file === localizationImplementation) continue; // the L10n implementation itself const text = readFileSync(file, "utf8"); for (const m of text.matchAll(/(? v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); for (const file of swiftFiles(sourceRoot)) { - if (file.endsWith("Localization/InterfaceLanguage.swift")) continue; + if (file === localizationImplementation) continue; const text = readFileSync(file, "utf8"); const where = (index) => `${relative(root, file)}:${text.slice(0, index).split("\n").length}`; diff --git a/scripts/verify-debug-v1-baseline.mjs b/scripts/verify-debug-v1-baseline.mjs index eb532be3d8..15a5820dd2 100755 --- a/scripts/verify-debug-v1-baseline.mjs +++ b/scripts/verify-debug-v1-baseline.mjs @@ -4,8 +4,9 @@ // unified debug engine must pass before any old session path is removed. import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; -const root = new URL("..", import.meta.url).pathname; +const root = fileURLToPath(new URL("..", import.meta.url)); const manifestPath = "shared/fixtures/debug/v1-baseline.json"; const manifest = JSON.parse(readFileSync(join(root, manifestPath), "utf8")); const failures = []; diff --git a/scripts/windows-build-preflight.ps1 b/scripts/windows-build-preflight.ps1 new file mode 100644 index 0000000000..44c226a8aa --- /dev/null +++ b/scripts/windows-build-preflight.ps1 @@ -0,0 +1,107 @@ +# Build-only preparation; no download, installation or published resource writes. +function Initialize-WindowsNativeBun { + param([Parameter(Mandatory)][string]$RepositoryRoot, [string]$NativeBunPath = "") + + $candidates = @() + if ($NativeBunPath) { + $candidates = @($NativeBunPath) + } else { + $native = Get-Command bun.exe -CommandType Application -ErrorAction SilentlyContinue + if ($native) { $candidates += $native.Source } + # npm's PowerShell shim is runnable by PowerShell, but spawnSync cannot + # execute it. Resolve the adjacent native package without running npm. + $shim = Get-Command bun -ErrorAction SilentlyContinue + if ($shim -and $shim.Source) { + $candidates += Join-Path (Split-Path -Parent $shim.Source) "node_modules/bun/bin/bun.exe" + } + if ($env:BUN_INSTALL) { $candidates += Join-Path $env:BUN_INSTALL "bin/bun.exe" } + $candidates += Join-Path ([Environment]::GetFolderPath("UserProfile")) ".bun/bin/bun.exe" + } + $executable = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if (-not $executable -or [IO.Path]::GetFileName($executable) -ne "bun.exe") { + throw "A native bun.exe is required before resource verification; supply -NativeBunPath." + } + # PowerShell's location can differ from the native process working directory. + $executable = (Get-Item -LiteralPath $executable).FullName + $package = Get-Content -Raw -LiteralPath (Join-Path $RepositoryRoot "windows/tauri/package.json") | ConvertFrom-Json + $expected = ([string]$package.packageManager) -replace '^bun@', '' + if (-not $expected -or $expected -eq $package.packageManager) { throw "packageManager must pin Bun." } + $process = [Diagnostics.Process]::new() + $process.StartInfo = [Diagnostics.ProcessStartInfo]::new($executable) + $process.StartInfo.WorkingDirectory = $RepositoryRoot + $process.StartInfo.ArgumentList.Add("--version") + $process.StartInfo.UseShellExecute = $false + $process.StartInfo.CreateNoWindow = $true + $process.StartInfo.RedirectStandardOutput = $true + $process.StartInfo.RedirectStandardError = $true + $started = $false + try { + $started = $process.Start() + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(10000)) { throw "Native Bun version inspection exceeded 10 seconds." } + if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout, $stderr), 2000)) { + throw "Native Bun version output did not close within 2 seconds." + } + if ($process.ExitCode -ne 0 -or $stdout.Result.Trim() -ne $expected) { + throw "Native Bun must match packageManager ($expected)." + } + } finally { + try { + if ($started -and -not $process.HasExited) { + $process.Kill($true) + if (-not $process.WaitForExit(5000)) { throw "Native Bun version process survived cleanup." } + } + } finally { $process.Dispose() } + } + # Establish this before Node cache verifiers and inherited install workers. + $env:PATH = (Split-Path -Parent $executable) + [IO.Path]::PathSeparator + $env:PATH +} + +function Invoke-TauriWithOriginalManifestBytes { + param([Parameter(Mandatory)][string]$Manifest, [Parameter(Mandatory)][scriptblock]$Action) + + $original = [IO.File]::ReadAllBytes($Manifest) + # Admit exactly CRLF -> LF, without decoding, TOML serialization or a + # semantic hash. Every other byte remains part of the source identity. + $lf = [Collections.Generic.List[byte]]::new() + for ($index = 0; $index -lt $original.Length; $index++) { + if ($original[$index] -eq 13 -and $index + 1 -lt $original.Length -and $original[$index + 1] -eq 10) { continue } + $lf.Add($original[$index]) + } + $originalKey = [Convert]::ToBase64String($original) + $lfKey = [Convert]::ToBase64String($lf.ToArray()) + $failure = $null + try { & $Action } catch { $failure = $_ } + $stream = $null + try { + # Compare and restore through one exclusive handle. Opening an existing + # file fails on deletion or a conflicting owner rather than recreating + # it or overwriting an edit between a separate read and write. + $stream = [IO.File]::Open($Manifest, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + $current = [byte[]]::new($stream.Length) + $stream.ReadExactly($current, 0, $current.Length) + $currentKey = [Convert]::ToBase64String($current) + if ($currentKey -eq $originalKey) { + # Leave unchanged bytes and file timestamps untouched. + } elseif ($currentKey -eq $lfKey) { + $stream.Position = 0 + $stream.Write($original, 0, $original.Length) + $stream.SetLength($original.Length) + $stream.Flush($true) + } else { + throw "Tauri changed Cargo.toml beyond the admitted CRLF-to-LF conversion; current bytes were preserved." + } + } catch { + if ($null -ne $failure) { + # Neither the action failure nor a later rejected restoration is + # allowed to hide the other diagnostic. + throw [AggregateException]::new("Tauri manifest check failed. Prior build failure: $($failure.Exception.Message)", + [Exception[]]@($_.Exception, $failure.Exception)) + } + throw + } finally { + if ($null -ne $stream) { $stream.Dispose() } + } + if ($null -ne $failure) { throw $failure } +} diff --git a/scripts/windows-frontend-install.test.ts b/scripts/windows-frontend-install.test.ts index 76d948240c..719f872daf 100644 --- a/scripts/windows-frontend-install.test.ts +++ b/scripts/windows-frontend-install.test.ts @@ -1,12 +1,55 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, describe, expect, test } from "bun:test"; +import { dlopen, FFIType, ptr, type Pointer } from "bun:ffi"; +import { randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; +import net from "node:net"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { Worker } from "node:worker_threads"; import { runProcess } from "../.agents/skills/write-stable-tests/scripts/test-timing-lib.mjs"; const scripts = path.dirname(fileURLToPath(import.meta.url)); -const dependencyPaths = ["node_modules", "windows/tauri/node_modules", "frontend/editor/node_modules"]; +const dependencyPaths = [ + "node_modules", + "windows/tauri/node_modules", + "frontend/editor/node_modules", +]; +const kernel = + process.platform === "win32" + ? dlopen("kernel32.dll", { + OpenProcess: { args: [FFIType.u32, FFIType.bool, FFIType.u32], returns: FFIType.ptr }, + GetProcessTimes: { + args: [FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], + returns: FFIType.bool, + }, + WaitForSingleObject: { args: [FFIType.ptr, FFIType.u32], returns: FFIType.u32 }, + TerminateProcess: { args: [FFIType.ptr, FFIType.u32], returns: FFIType.bool }, + CloseHandle: { args: [FFIType.ptr], returns: FFIType.bool }, + GetLastError: { args: [], returns: FFIType.u32 }, + }) + : undefined; +afterAll(() => kernel?.close()); +type OwnedProcess = { pid: number; handle: Pointer; creationFileTime: string }; + +// Wait off the test event loop on retained kernel handles, never a PID looked up +// after worker exit. The test thread alone owns and closes these handles. +const processWaiter = String.raw` +const { parentPort, workerData } = require("node:worker_threads"); +const { dlopen, FFIType } = require("bun:ffi"); +const kernel = dlopen("kernel32.dll", { + WaitForSingleObject: { args: [FFIType.ptr, FFIType.u32], returns: FFIType.u32 }, +}); +try { + const deadline = performance.now() + workerData.timeoutMs; + const states = workerData.handles.map(handle => + kernel.symbols.WaitForSingleObject(handle, Math.max(0, Math.ceil(deadline - performance.now())))); + parentPort.postMessage(states); +} finally { + kernel.close(); + parentPort.close(); +} +`; // A real child process keeps its current directory locked on Windows. IPC // confirms that lock before the fake installer exits, without sleeps or polling. @@ -21,8 +64,23 @@ record("started"); if (process.argv.includes("--locker")) { const server = net.createServer(); server.listen(0, "127.0.0.1", () => { - record("locked"); - process.send("locked"); + const ownership = net.connect(Number(process.env.LITHE_INSTALL_OWNER_PORT), "127.0.0.1"); + ownership.setTimeout(5000, () => { throw Error("Locker ownership handshake timed out"); }); + ownership.once("error", error => { throw error; }); + ownership.once("connect", () => ownership.write(JSON.stringify({ + pid: process.pid, token: process.env.LITHE_INSTALL_OWNER_TOKEN + }) + "\n")); + let acknowledgement = ""; + ownership.on("data", chunk => { + acknowledgement += chunk; + if (acknowledgement.length > 16) throw Error("Invalid ownership acknowledgement"); + if (!acknowledgement.endsWith("\n")) return; + if (acknowledgement !== "owned\n") throw Error("Locker ownership was not acknowledged"); + ownership.removeAllListeners("data"); + ownership.end(); + record("locked"); + process.send("locked"); + }); }); } else if (process.argv.includes("--version")) { console.log("1.3.12"); @@ -54,16 +112,12 @@ if (process.argv.includes("--locker")) { } `; -function isRunning(pid: number) { - try { process.kill(pid, 0); return true; } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; - throw error; - } -} - async function ownedPids(root: string) { try { - return (await fs.readFile(path.join(root, "owned-pids"), "utf8")).trim().split(/\s+/).map(Number); + return (await fs.readFile(path.join(root, "owned-pids"), "utf8")) + .trim() + .split(/\s+/) + .map(Number); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; @@ -76,83 +130,205 @@ async function ownedPids(root: string) { const fixtureInstallTimeoutSeconds = 5; const fixtureProcessTimeoutMs = 45000; -// Closing the worker's Job Object requests descendant termination; worker exit -// does not signal that every descendant has finished exiting. Wait on native -// process handles in a separate PowerShell process, with one shared deadline. -async function waitForOwnedProcesses(root: string) { - const pids = await ownedPids(root); - if (pids.length === 0) return; - const result = await runProcess({ - command: "pwsh", - args: ["-NoProfile", "-Command", String.raw` - $ErrorActionPreference = "Stop" - $deadline = [Diagnostics.Stopwatch]::StartNew() - foreach ($ownedPid in ($env:LITHE_INSTALL_OWNED_PIDS -split ',')) { - try { $ownedProcess = [Diagnostics.Process]::GetProcessById([int]$ownedPid) } - catch [ArgumentException] { continue } - try { - $remaining = [Math]::Max(0, 5000 - [int]$deadline.ElapsedMilliseconds) - if (-not $ownedProcess.WaitForExit($remaining)) { - throw "Installer descendant $ownedPid did not exit within the shared 5 second cleanup deadline." - } - } finally { $ownedProcess.Dispose() } - } - exit 0 - `], - env: { ...process.env, LITHE_INSTALL_OWNED_PIDS: pids.join(",") }, - timeoutMs: 10000, +async function waitForOwnedProcesses(root: string, owned: OwnedProcess[]) { + if (owned.length === 0) return; + const states = await new Promise((resolve, reject) => { + const worker = new Worker(path.join(root, "bin/process-waiter.cjs"), { + workerData: { handles: owned.map((process) => process.handle), timeoutMs: 5000 }, + }); + let result: number[] | undefined; + let failure: Error | undefined; + const timer = setTimeout(() => { + failure = new Error("Native process-handle waiter exceeded its 7 second deadline"); + // terminate() completes after the native wait thread exits. Never let the + // owner close shared handles while this thread could still be using them. + void worker.terminate().then(() => reject(failure), reject); + }, 7000); + worker.once("message", (states) => { + result = states; + }); + worker.once("error", (error) => { + failure = error; + }); + worker.once("exit", (code) => { + clearTimeout(timer); + if (failure) reject(failure); + else if (code !== 0 || !result) + reject(new Error(`Native process-handle waiter exited ${code} without results`)); + else resolve(result); + }); }); - if (result.code !== 0 || result.timedOut || !result.terminationConfirmed) { - throw new Error(`Installer descendant termination was not confirmed: code=${result.code}, signal=${result.signal}, timedOut=${result.timedOut}, terminationConfirmed=${result.terminationConfirmed}\n${result.stdout}\n${result.stderr}\n${await installerDiagnostics(root)}`); + const receipts = owned.map(({ pid, creationFileTime }, index) => ({ + pid, + creationFileTime, + waitStatus: states[index], + })); + await fs.appendFile(path.join(root, "process-identity.jsonl"), JSON.stringify(receipts) + "\n"); + if (states.some((state) => state !== 0)) { + throw new Error( + `Original installer descendants did not exit within the shared 5 second deadline: ${JSON.stringify(receipts)}\n${await installerDiagnostics(root)}`, + ); } } async function installerDiagnostics(root: string) { - const files = ["events.jsonl", "attempts.jsonl", "owned-pids", "retry-succeeded", "sealed-state"]; - return (await Promise.all(files.map(async name => { - try { - return `${name}:\n${await fs.readFile(path.join(root, name), "utf8")}`; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return `${name}: not reached`; - throw error; - } - }))).join("\n"); + const files = [ + "events.jsonl", + "attempts.jsonl", + "owned-pids", + "process-identity.jsonl", + "retry-succeeded", + "sealed-state", + ]; + return ( + await Promise.all( + files.map(async (name) => { + try { + return `${name}:\n${await fs.readFile(path.join(root, name), "utf8")}`; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return `${name}: not reached`; + throw error; + } + }), + ) + ).join("\n"); } -async function withInstaller(mode: string, check: (root: string, result: Awaited>) => Promise, timeoutSeconds = fixtureInstallTimeoutSeconds) { +async function withInstaller( + mode: string, + check: (root: string, result: Awaited>) => Promise, + timeoutSeconds = fixtureInstallTimeoutSeconds, +) { const root = await fs.mkdtemp(path.join(os.tmpdir(), "lithe Windows install ")); + const owned: OwnedProcess[] = []; + const sockets = new Set(); + const ownershipErrors: string[] = []; + const ownerToken = randomUUID(); + let primaryError: unknown; + const owner = net.createServer((socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + socket.on("error", (error) => ownershipErrors.push(String(error))); + socket.setTimeout(5000, () => socket.destroy(new Error("Process ownership request timed out"))); + let message = ""; + socket.on("data", (chunk) => { + message += chunk; + if (message.length > 1024) { + socket.destroy(new Error("Process ownership request too large")); + return; + } + if (!message.endsWith("\n")) return; + try { + const { pid, token } = JSON.parse(message); + if ( + token !== ownerToken || + !Number.isInteger(pid) || + pid <= 0 || + owned.some((process) => process.pid === pid) + ) + throw new Error("Invalid process ownership request"); + // SYNCHRONIZE, QUERY_LIMITED_INFORMATION and TERMINATE only. Acquire + // while the locker waits for ACK, before its parent can exit the Job. + const handle = kernel!.symbols.OpenProcess(0x100000 | 0x1000 | 0x1, false, pid); + if (!handle) throw new Error(`OpenProcess failed: ${kernel!.symbols.GetLastError()}`); + try { + const times = Array.from({ length: 4 }, () => new BigUint64Array(1)); + if ( + !kernel!.symbols.GetProcessTimes( + handle, + ptr(times[0]), + ptr(times[1]), + ptr(times[2]), + ptr(times[3]), + ) + ) + throw new Error(`GetProcessTimes failed: ${kernel!.symbols.GetLastError()}`); + if (kernel!.symbols.WaitForSingleObject(handle, 0) !== 258) + throw new Error("Locker exited before ownership acknowledgement"); + owned.push({ pid, handle, creationFileTime: String(times[0][0]) }); + } catch (error) { + kernel!.symbols.CloseHandle(handle); + throw error; + } + socket.end("owned\n"); + } catch (error) { + ownershipErrors.push(String(error)); + socket.destroy(); + } + }); + }); try { + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("Ownership server startup exceeded 5 seconds")), + 5000, + ); + owner.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + owner.listen(0, "127.0.0.1", () => { + clearTimeout(timer); + resolve(); + }); + }); await fs.mkdir(path.join(root, "scripts")); await fs.mkdir(path.join(root, "windows/tauri"), { recursive: true }); await fs.mkdir(path.join(root, "bin")); await fs.mkdir(path.join(root, ".artifacts/bun-cache"), { recursive: true }); - for (const name of ["install-windows-frontend-dependencies.ps1", "invoke-windows-bun-install.ps1"]) { + for (const name of [ + "install-windows-frontend-dependencies.ps1", + "invoke-windows-bun-install.ps1", + "windows-tree-sitter-proxy.cjs", + ]) { await fs.copyFile(path.join(scripts, name), path.join(root, "scripts", name)); } - await fs.writeFile(path.join(root, "windows/tauri/package.json"), JSON.stringify({ packageManager: "bun@1.3.12" })); - await fs.writeFile(path.join(root, ".artifacts/bun-cache/.lithe-integrity.json"), "old manifest"); + await fs.writeFile( + path.join(root, "windows/tauri/package.json"), + JSON.stringify({ packageManager: "bun@1.3.12" }), + ); + await fs.writeFile( + path.join(root, ".artifacts/bun-cache/.lithe-integrity.json"), + "old manifest", + ); await fs.writeFile(path.join(root, ".artifacts/bun-cache/old-cache"), "old"); await fs.writeFile(path.join(root, "bin/fake-bun.cjs"), fakeBun); - await fs.writeFile(path.join(root, "bin/bun.cmd"), '@echo off\r\nnode "%~dp0fake-bun.cjs" %*\r\n'); + await fs.writeFile(path.join(root, "bin/process-waiter.cjs"), processWaiter); + await fs.writeFile( + path.join(root, "bin/bun.cmd"), + '@echo off\r\nnode "%~dp0fake-bun.cjs" %*\r\n', + ); // Cache validation is covered separately; here the sealer records whether // the fallback invalidated its verified flag and allowed a new manifest. - await fs.writeFile(path.join(root, "scripts/verify-download-cache.mjs"), ` + await fs.writeFile( + path.join(root, "scripts/verify-download-cache.mjs"), + ` import { writeFileSync } from "node:fs"; import path from "node:path"; const root = process.env.LITHE_INSTALL_FIXTURE_ROOT; writeFileSync(path.join(root, "sealed-state"), process.env.LITHE_BUN_CACHE_VERIFIED); writeFileSync(path.join(root, ".artifacts/bun-cache/.lithe-integrity.json"), "new manifest"); - `); + `, + ); const result = await runProcess({ command: "pwsh", - args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path.join(root, "scripts/install-windows-frontend-dependencies.ps1"), - "-InstallTimeoutSeconds", String(timeoutSeconds)], + args: [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + path.join(root, "scripts/install-windows-frontend-dependencies.ps1"), + "-InstallTimeoutSeconds", + String(timeoutSeconds), + ], cwd: root, env: { ...process.env, PATH: path.join(root, "bin") + path.delimiter + process.env.PATH, LITHE_INSTALL_FIXTURE_ROOT: root, LITHE_INSTALL_FIXTURE_MODE: mode, + LITHE_INSTALL_OWNER_PORT: String((owner.address() as net.AddressInfo).port), + LITHE_INSTALL_OWNER_TOKEN: ownerToken, LITHE_BUN_CACHE_VERIFIED: "true", GITHUB_ACTIONS: "false", GITHUB_ENV: "", @@ -160,32 +336,101 @@ async function withInstaller(mode: string, check: (root: string, result: Awaited timeoutMs: fixtureProcessTimeoutMs, }); if (result.timedOut || !result.terminationConfirmed) { - throw new Error(`Windows install fixture (${mode}): duration=${Math.round(result.durationMs)}ms, outerDeadline=${fixtureProcessTimeoutMs}ms, installDeadline=${timeoutSeconds}s, timedOut=${result.timedOut}, terminationConfirmed=${result.terminationConfirmed}\n${await installerDiagnostics(root)}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + throw new Error( + `Windows install fixture (${mode}): duration=${Math.round(result.durationMs)}ms, outerDeadline=${fixtureProcessTimeoutMs}ms, installDeadline=${timeoutSeconds}s, timedOut=${result.timedOut}, terminationConfirmed=${result.terminationConfirmed}\n${await installerDiagnostics(root)}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); } + expect(ownershipErrors).toEqual([]); await check(root, result); - await waitForOwnedProcesses(root); - expect((await ownedPids(root)).filter(isRunning)).toEqual([]); - } finally { - // Cleanup remains owned even if a worker or assertion fails. - for (const pid of await ownedPids(root)) { - if (isRunning(pid)) { - await runProcess({ command: "taskkill.exe", args: ["/PID", String(pid), "/T", "/F"], timeoutMs: 5000 }); + expect(owned.map((process) => process.pid)).toEqual(await ownedPids(root)); + await waitForOwnedProcesses(root, owned); + } catch (error) { + primaryError = error; + } + // Even failed assertions clean only the exact originally retained objects. + // Preserve the original failure and keep cleaning the remaining resources if + // an individual native operation fails. + const cleanupErrors: unknown[] = []; + const forced: number[] = []; + for (const process of owned) { + const state = kernel!.symbols.WaitForSingleObject(process.handle, 0); + if (state === 258) { + forced.push(process.pid); + if ( + !kernel!.symbols.TerminateProcess(process.handle, 1) && + kernel!.symbols.WaitForSingleObject(process.handle, 0) !== 0 + ) { + cleanupErrors.push( + new Error( + `TerminateProcess failed for captured identity: ${kernel!.symbols.GetLastError()}`, + ), + ); } + } else if (state !== 0) { + cleanupErrors.push(new Error(`WaitForSingleObject failed for captured identity: ${state}`)); } - await waitForOwnedProcesses(root); + } + try { + await waitForOwnedProcesses(root, owned); + } catch (error) { + cleanupErrors.push(error); + } + const receipts = owned.map(({ pid, handle, creationFileTime }) => ({ + pid, + creationFileTime, + waitStatus: kernel!.symbols.WaitForSingleObject(handle, 0), + })); + for (const process of owned) { + if (!kernel!.symbols.CloseHandle(process.handle)) + cleanupErrors.push(new Error(`CloseHandle failed: ${kernel!.symbols.GetLastError()}`)); + } + for (const socket of sockets) socket.destroy(); + try { + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("Ownership server cleanup exceeded 5 seconds")), + 5000, + ); + owner.close(() => { + clearTimeout(timer); + resolve(); + }); + }); + } catch (error) { + cleanupErrors.push(error); + } + try { await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch (error) { + cleanupErrors.push(error); } + console.log( + `Installer identity cleanup: ${JSON.stringify({ mode, forced, processes: receipts, serverListening: owner.listening, sockets: sockets.size, cleanupErrors: cleanupErrors.map(String) })}`, + ); + if (cleanupErrors.length) + throw new AggregateError( + [...(primaryError ? [primaryError] : []), ...cleanupErrors], + "Installer fixture or cleanup failed", + ); + if (primaryError) throw primaryError; } describe.skipIf(process.platform !== "win32")("Windows dependency install lifecycle", () => { test("failed installation releases locked descendants before its clean retry", async () => { await withInstaller("recover", async (root, result) => { expect(result.code, result.stdout + result.stderr).toBe(0); - const attempts = (await fs.readFile(path.join(root, "attempts.jsonl"), "utf8")).trim().split("\n").map(line => JSON.parse(line)); - expect(attempts).toEqual([["install", "--frozen-lockfile"], ["install", "--frozen-lockfile", "--no-cache"]]); + const attempts = (await fs.readFile(path.join(root, "attempts.jsonl"), "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(attempts).toEqual([ + ["install", "--frozen-lockfile"], + ["install", "--frozen-lockfile", "--no-cache"], + ]); expect(await fs.readFile(path.join(root, "retry-succeeded"), "utf8")).toBe("yes"); expect(await fs.readFile(path.join(root, "sealed-state"), "utf8")).toBe("false"); - for (const directory of dependencyPaths) expect(await fs.readdir(path.join(root, directory))).toEqual([]); + for (const directory of dependencyPaths) + expect(await fs.readdir(path.join(root, directory))).toEqual([]); expect((await ownedPids(root)).length).toBe(1); }); }); @@ -201,19 +446,27 @@ describe.skipIf(process.platform !== "win32")("Windows dependency install lifecy test("successful installation releases leftover children without clearing a valid cache", async () => { await withInstaller("success-with-child", async (root, result) => { expect(result.code, result.stdout + result.stderr).toBe(0); - expect(await fs.readFile(path.join(root, ".artifacts/bun-cache/old-cache"), "utf8")).toBe("old"); + expect(await fs.readFile(path.join(root, ".artifacts/bun-cache/old-cache"), "utf8")).toBe( + "old", + ); expect((await ownedPids(root)).length).toBe(1); - const attempts = (await fs.readFile(path.join(root, "attempts.jsonl"), "utf8")).trim().split("\n"); + const attempts = (await fs.readFile(path.join(root, "attempts.jsonl"), "utf8")) + .trim() + .split("\n"); expect(attempts.length).toBe(1); }); }); test("a stalled installer reaches its local deadline and releases its descendants", async () => { - await withInstaller("timeout", async (root, result) => { - expect(result.code).not.toBe(0); - expect(result.stderr).toContain("exceeded its 3 second deadline"); - expect(result.stderr).toContain("failed after a clean retry"); - expect((await ownedPids(root)).length).toBe(2); - }, 3); + await withInstaller( + "timeout", + async (root, result) => { + expect(result.code).not.toBe(0); + expect(result.stderr).toContain("exceeded its 3 second deadline"); + expect(result.stderr).toContain("failed after a clean retry"); + expect((await ownedPids(root)).length).toBe(2); + }, + 3, + ); }); }); diff --git a/scripts/windows-tree-sitter-proxy.cjs b/scripts/windows-tree-sitter-proxy.cjs new file mode 100644 index 0000000000..58f8cca1e5 --- /dev/null +++ b/scripts/windows-tree-sitter-proxy.cjs @@ -0,0 +1,53 @@ +// Keep upstream install.js and TLS verification; Node owns CONNECT and redirects. +const path = require('node:path'); +const fs = require('node:fs'); +const https = require('node:https'); + +function configureProxy({ argv, env, agentModule, version, readPackage }) { + if (!argv[1] || path.basename(argv[1]) !== 'install.js') return false; + // Match native proxyEnv precedence, including an empty lowercase fallback. + const proxy = env.https_proxy || env.HTTPS_PROXY; + if (!proxy) return false; + let manifest; + try { + manifest = readPackage(path.join(path.dirname(path.resolve(argv[1])), 'package.json')); + } catch { + // An unidentified script keeps its original behavior and diagnostics. + return false; + } + if (manifest.name !== 'tree-sitter-cli') return false; + const [major, minor] = version.split('.').map(Number); + if (!(major > 24 || (major === 24 && minor >= 5) || (major === 22 && minor >= 21))) { + throw new Error('Proxied tree-sitter installation requires Node 22.21+ or 24.5+ with native proxyEnv support.'); + } + let agent; + try { + // Native Agent ignores unsupported schemes instead of throwing. Reject + // them before deleting upstream's route, which would otherwise go direct. + if (typeof proxy !== 'string' || !/^https?:\/\//.test(proxy) || /[\r\n\t]/.test(proxy)) { + throw new Error('Unsupported proxy URL'); + } + const parsed = new URL(proxy); + if (!parsed.hostname) throw new Error('Missing proxy host'); + decodeURIComponent(parsed.username); + decodeURIComponent(parsed.password); + agent = new agentModule.Agent({ proxyEnv: { ...env } }); + } catch { + // URL and native Agent errors may embed credentials. Do not attach a cause. + const error = new Error('Proxied tree-sitter installation requires a valid http:// or https:// proxy URL with valid encoded credentials.'); + error.code = 'LITHE_INVALID_TREE_SITTER_PROXY'; + throw error; + } + // Snapshot before removing the two variables consumed by upstream's manual + // CONNECT branch. Only this lifecycle process changes; Bun/other packages keep + // their environment. Node retains proxy credentials privately, with normal TLS. + agentModule.globalAgent = agent; + delete env.HTTPS_PROXY; + delete env.https_proxy; + return true; +} + +module.exports = { configureProxy }; +configureProxy({ argv: process.argv, env: process.env, agentModule: https, + version: process.versions.node, + readPackage: file => JSON.parse(fs.readFileSync(file, 'utf8')) }); diff --git a/scripts/windows-tree-sitter-proxy.test.cjs b/scripts/windows-tree-sitter-proxy.test.cjs new file mode 100644 index 0000000000..f8245e32e0 --- /dev/null +++ b/scripts/windows-tree-sitter-proxy.test.cjs @@ -0,0 +1,120 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const https = require('node:https'); +const { configureProxy } = require('./windows-tree-sitter-proxy.cjs'); + +function fixture(overrides = {}) { + const options = { argv: ['node', 'install.js'], env: { HTTPS_PROXY: 'http://proxy.invalid:8080', + NO_PROXY: 'localhost', NODE_OPTIONS: '--trace-warnings' }, + agentModule: { globalAgent: 'original', Agent: class { constructor(config) { this.config = config; } } }, + version: '24.15.0', readPackage: () => ({ name: 'tree-sitter-cli' }), ...overrides }; + return options; +} + +test('native Agent snapshots proxy while only the targeted lifecycle environment changes', { timeout: 1000 }, () => { + const options = fixture(); + assert.equal(configureProxy(options), true); + assert.equal(options.agentModule.globalAgent.config.proxyEnv.HTTPS_PROXY, 'http://proxy.invalid:8080'); + assert.deepEqual(options.env, { NO_PROXY: 'localhost', NODE_OPTIONS: '--trace-warnings' }); + assert.equal(options.agentModule.globalAgent.config.rejectUnauthorized, undefined); +}); + +test('lowercase proxy and no-proxy settings retain Node precedence', { timeout: 1000 }, () => { + const options = fixture({ env: { https_proxy: 'http://proxy.invalid:8080', no_proxy: 'localhost' } }); + assert.equal(configureProxy(options), true); + assert.equal(options.agentModule.globalAgent.config.proxyEnv.no_proxy, 'localhost'); + assert.equal(options.env.https_proxy, undefined); +}); + +test('lowercase selection and empty fallback match native Agent precedence', { timeout: 1000 }, () => { + for (const env of [ + { https_proxy: 'http://lower.invalid:8080', HTTPS_PROXY: 'socks5://ignored.invalid:1080' }, + { https_proxy: '', HTTPS_PROXY: 'https://upper.invalid:8443' }, + ]) { + const options = fixture({ env: { ...env }, agentModule: { globalAgent: 'original', Agent: https.Agent } }); + try { + assert.equal(configureProxy(options), true); + assert.deepEqual(options.agentModule.globalAgent.options.proxyEnv, env); + assert.equal(options.agentModule.globalAgent.options.rejectUnauthorized, undefined); + assert.deepEqual(options.env, {}); + } finally { + if (options.agentModule.globalAgent !== 'original') options.agentModule.globalAgent.destroy(); + } + } + const direct = fixture({ env: { https_proxy: '', HTTPS_PROXY: '' } }); + assert.equal(configureProxy(direct), false); + assert.equal(direct.agentModule.globalAgent, 'original'); + assert.deepEqual(direct.env, { https_proxy: '', HTTPS_PROXY: '' }); +}); + +test('unsupported or malformed selected proxies fail before route mutation', { timeout: 1000 }, () => { + for (const proxy of [ + 'socks5://proxy.invalid:1080', 'proxy.invalid:8080', 'HTTP://proxy.invalid:8080', + 'http://', 'http://proxy.invalid:99999', 'http://proxy.invalid\r\n', 'http://pro\txy.invalid', + ]) { + const options = fixture({ env: { https_proxy: proxy, HTTPS_PROXY: 'http://fallback.invalid:8080' }, + agentModule: { globalAgent: 'original', Agent: https.Agent } }); + const before = { ...options.env }; + assert.throws(() => configureProxy(options), { code: 'LITHE_INVALID_TREE_SITTER_PROXY' }); + assert.deepEqual(options.env, before); + assert.equal(options.agentModule.globalAgent, 'original'); + } +}); + +test('credential and native configuration failures never expose their inputs', { timeout: 1000 }, () => { + const secret = 'fixture-sensitive-password'; + const proxy = `http://user%ZZ:${secret}@proxy.invalid:8080`; + for (const override of [ + { env: { HTTPS_PROXY: proxy } }, + { env: { HTTPS_PROXY: `http://user:${secret}%ZZ@proxy.invalid:8080` } }, + { env: { HTTPS_PROXY: `http://user:${secret}@proxy.invalid:99999` } }, + { agentModule: { globalAgent: 'original', Agent: class { constructor() { throw new Error(secret); } } } }, + ]) { + const options = fixture(override); + const before = { ...options.env }; + assert.throws(() => configureProxy(options), error => { + assert.equal(error.code, 'LITHE_INVALID_TREE_SITTER_PROXY'); + assert.equal(error.cause, undefined); + assert.ok(!error.stack.includes(secret)); + assert.ok(!error.stack.includes(proxy)); + return true; + }); + assert.deepEqual(options.env, before); + assert.equal(options.agentModule.globalAgent, 'original'); + } +}); + +test('native Agent accepts encoded credentials without changing TLS defaults', { timeout: 1000 }, () => { + const env = { HTTPS_PROXY: 'https://fixture%40user:fixture%3Apassword@proxy.invalid:8443', NO_PROXY: 'localhost' }; + const options = fixture({ env: { ...env }, agentModule: { globalAgent: 'original', Agent: https.Agent } }); + try { + assert.equal(configureProxy(options), true); + assert.ok(options.agentModule.globalAgent instanceof https.Agent); + assert.deepEqual(options.agentModule.globalAgent.options.proxyEnv, env); + assert.equal(options.agentModule.globalAgent.options.rejectUnauthorized, undefined); + assert.deepEqual(options.env, { NO_PROXY: 'localhost' }); + } finally { + if (options.agentModule.globalAgent !== 'original') options.agentModule.globalAgent.destroy(); + } +}); + +test('unrelated scripts, other packages and direct installs keep upstream behavior', { timeout: 1000 }, () => { + for (const override of [{ argv: ['node', 'other.js'] }, { readPackage: () => ({ name: 'other' }) }, + { readPackage: () => { throw new Error('unidentified script'); } }, { env: {} }]) { + const options = fixture(override); + const before = { ...options.env }; + assert.equal(configureProxy(options), false); + assert.deepEqual(options.env, before); + assert.equal(options.agentModule.globalAgent, 'original'); + } +}); + +test('unsupported Node fails before mutation; supported LTS versions use native capability', { timeout: 1000 }, () => { + for (const version of ['20.19.0', '22.20.0', '24.4.0']) { + const options = fixture({ version }); + assert.throws(() => configureProxy(options), /native proxyEnv support/); + assert.equal(options.env.HTTPS_PROXY, 'http://proxy.invalid:8080'); + assert.equal(options.agentModule.globalAgent, 'original'); + } + for (const version of ['22.21.0', '24.5.0', '25.0.0']) assert.equal(configureProxy(fixture({ version })), true); +}); diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 91b36a3a45..8e7289485b 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -14,7 +14,8 @@ { "id": "bun", "path": ".artifacts/bun-cache", - "validator": "bun" + "validator": "bun", + "identity": "Bun version, exact bun.lock bytes (including official registry URLs), and sealed file inventory; a source-only lock migration invalidates the old seal. Lifecycle executables are installation state, not npm tarball-authenticated reusable downloads." }, { "id": "jdtls", @@ -34,15 +35,54 @@ ], "excludedResources": [ { - "id": "swiftpm-build-state", + "id": "native-browser-regression-state", "reusable": false, "locations": [ - ".build/", - ".build/.lithe-swift-toolchain", - ".build//.lithe-swift-toolchain" + ".artifacts/native-browser-*/", + ".artifacts/native-browser-*/profile/", + ".artifacts/native-browser-*/cache/", + ".artifacts/native-browser-*/worker-evidence.json", + ".artifacts/native-browser-*/job-result.json", + ".artifacts/native-browser-*/worker.log", + ".artifacts/test-stability/native-panel-browser.{json,html,junit.xml}", + ".artifacts/test-stability/native-panel-browser.worker.log", + ".artifacts/test-stability/native-tree-browser.{json,html,junit.xml,worker.log}", + ".artifacts/test-stability/native-form-grid-browser.{json,html,junit.xml,worker.log}", + ".artifacts/test-stability/native-controls-input-browser.{json,html,junit.xml,worker.log}", + ".artifacts/test-stability/native-workbench-browser.{json,html,junit.xml,worker.log}", + ".artifacts/test-stability/native-browser-{worker-exit,worker-hang}.{json,html,junit.xml,worker.log}", + ".{json,html,junit.xml,worker.log}", + "/expected-failures/.{json,html,junit.xml}" ], - "identity": "Mutable worktree-owned SwiftPM products, resource bundles, build database and scratch-local compiler identity. The versioned full compiler stamp only controls local invalidation; it is not a sealed source/platform/architecture/SDK/toolchain identity for cross-worktree reuse.", - "reason": "Never copy or share this state at any worktree reuse stage. build-macos.sh cleans the actual selected scratch directory with swift package --scratch-path when its compiler stamp changes or is unknown, retaining that scratch's checkouts and downloaded artifacts. Legacy layouts keep the default scratch. Each architecture owns its stamp; failed cleanup cannot advance it. Rebuild resource bundles before signing instead of mixing old flat resources with new Contents layouts or disabling signatures. Only the separately validated SwiftPM download cache is reusable; installed app bundles remain read-only and the Sparkle delta baseline is unaffected. SwiftPM owns build-state lifetime and cleanup." + "identity": "One controlled Windows action-group, form-grid, controls-input, workbench, tree keyboard/menu or injected lifecycle regression invocation owns its scratch profile, Vite optimizer cache, worker evidence, Job Object result, copied worker log and JSON/HTML/JUnit reports, including separate expected-failure evidence. Source, browser and viewport observations describe that run, not a reusable version/platform/architecture/toolchain build identity stamp.", + "reason": "Never copy these resources at any worktree stage. The runner owns bounded browser/driver/server process cleanup and removes scratch only after termination is proven; unresolved ownership remains isolated and fails the run. Reports remain with the invocation until local or CI retention removes them. Regenerate in the target worktree using its sources and configured dependencies. No state enters installed plugins, app resources, code signatures or Sparkle delta baselines." + }, + { + "id": "vcs-root-mappings", + "reusable": false, + "locations": [ + "/vcs-roots/.json", + ".json.-.tmp", + ".json.-.backup", + "/vcs-root-watch-leases" + ], + "identity": "Lithe-owned user choices bind a native workspace directory identity and each selected root directory identity. Window, registration, incarnation and scan generation own active reads and watches. These are mutable runtime ownership identities, never version/platform/architecture/toolchain build stamps.", + "reason": "Never copy mappings, guarded-save temporaries, recovery backups or watch leases at any worktree stage. Opening is read-only; explicit Save preserves unknown fields and permissions and rejects external byte changes. Failed saves remove their own temporary and retain recovery evidence. Closing a window/workspace or revoking plugins drops its watch leases; directory replacement cannot inherit choices. Storage comes from the platform app-local-data adapter, outside signed packages and update baselines." + }, + { + "id": "plugin-host-acceptance-state", + "reusable": false, + "locations": [ + ".artifacts/plugin-host-acceptance/", + "/lithe-build-preflight-*/", + "/", + "/vcs-move-/runtime/", + "/vcs-move-//{repo,wc,appdata,localappdata}/", + "/app.lithe.windows.host-acceptance/", + "/app.lithe.windows.host-acceptance/" + ], + "identity": "One isolated native acceptance or explicit fixed-source SVN move diagnostic invocation owns its temporary runtime, independent repo/WC/profiles, observed endpoint bytes/identity/WC schedules, and generated marketplace ZIPs/indexes, build-fixture packages/tools/workspaces/plan, settings-functions-fixture packages/workspaces/identity plan and resource snapshots, text-window-fixture* complete unsigned ui:1 ZIPs/workspaces/source plans, private host-page DOM/CSP/request/descriptor/callback traces and loopback-counter evidence, web-bridge-fallback-fixture static unsigned ui:2 ZIPs/workspaces/source plans and private-target CDP request/descriptor/callback/resource evidence, run image, reports, profiles and mutable installation state. Deterministic fixture hashes verify the selected test bytes, not a reusable source/platform/architecture/toolchain build stamp.", + "reason": "Regenerate fixtures in the target worktree. Never copy or share the run image, user profile, downloaded packages or TLS private keys through resource reuse. Offline build-preflight temporary trees contain copied scripts, fake native Bun/IPC workers, mutable manifests, recording stubs, fake PATH tar tools and small tar.gz/VSIX extraction inputs and outputs; their bytes prove only one test run, not reusable build identity. The driver owns bounded listeners and process handles, closes them on every path and removes its isolated app data; diagnostic artifacts remain with that run. Native HTTPS uses an existing trusted certificate without copying keys or mutating client trust. No writes enter published resources, signatures or update baselines." }, { "id": "agent-edit-and-paste-state", @@ -103,10 +143,11 @@ "node_modules", "windows/tauri/node_modules", "frontend/editor/node_modules", - ".artifacts/bun-tmp" + ".artifacts/bun-tmp", + "/lithe Windows install */" ], - "identity": "Mutable installation state tied to one worktree, OS, architecture, Bun version and lifecycle attempt; no sealed reusable identity stamp", - "reason": "Each Windows install attempt owns its lifecycle processes in a worker Job Object. Release that tree before clearing partial dependencies or retrying. Never copy install temp data or node_modules across worktrees; only the sealed Bun download cache may be reused." + "identity": "Mutable installation state tied to one worktree, OS, architecture, Bun version and lifecycle attempt; no sealed reusable identity stamp. Private installation-tool candidate metadata, tarballs, executables, route probes and SUBST aliases are diagnostics, never reusable build resources.", + "reason": "Each Windows install attempt owns its lifecycle processes in a worker Job Object. Release that tree before clearing partial dependencies or retrying. Installer test scratch includes generated fake tools, the native-handle waiter, ownership token/port and process-identity receipts; each case retains original handles until exit is proven and closes its server, sockets and handles before removing scratch. These mutable test resources never enter a transferable cache. Never copy install temp data or node_modules across worktrees; only the sealed Bun download cache may be reused. tree-sitter-cli lifecycle executables remain here: npm tarball SRI does not authenticate the separate GitHub binary, and an official null digest cannot establish a binary reuse identity." }, { "id": "jdt-maven-settings", @@ -137,13 +178,16 @@ "/Lithe/Plugins//", "Lithe.app/Contents/Resources/OfficialPlugins/dev.lithe.plugin.java-support/", "/Lithe/PluginState//", + "/plugin-state//", + "/plugin-state//build--.model.json", + "/plugin-state//build--.dependencies.json", "dist/packages///", "dist/verify///", "dist/Lithe-Java-Support--darwin-.zip", ".artifacts/java-plugin-distribution/", "/lithe-package-verification.*/published-index.fixture.json" ], - "identity": "Assembled plugin packages carry build-time downloads (JDT LS, its extensions and one JDK per target architecture for Java Support) and an optional publisher signature; no reliable worktree identity stamp. Plugin state directories are mutable language-server state of one installation. Independent release archives bind plugin version, darwin architecture, upstream input hashes, API/requires and final publisher signature; host-sealed bundled copies are a different origin, never a reusable package", + "identity": "Assembled plugin packages carry build-time downloads (JDT LS, its extensions and one JDK per target architecture for Java Support) and an optional publisher signature; no reliable worktree identity stamp. Plugin state directories are mutable state of one installation. Windows command-model and dependency output are fresh files owned by one window/workspace/operation, allocated with create_new after consent and removed after process-tree cleanup; cleanup failure retains the file with its owner, crash leftovers are plugin state removed on uninstall, with no reuse identity stamp; Assembled plugin packages carry build-time downloads (JDT LS, its extensions and one JDK per target architecture for Java Support) and an optional publisher signature; no reliable worktree identity stamp. Plugin state directories are mutable language-server state of one installation. Independent release archives bind plugin version, darwin architecture, upstream input hashes, API/requires and final publisher signature; host-sealed bundled copies are a different origin, never a reusable package", "reason": "Assemble each official declarative plugin independently in every worktree and validate it with LitheOfficialPluginVerifier; do not copy or symlink it across worktrees. Java Support is assembled from the reusable jdtls and jdk download caches by Plugins/mac/Official/JavaSupport/assemble-package.sh, so reuse those caches rather than the package. Never copy or share plugin state at any stage. Release verification extracts a separate copy and checks its publisher signature; delete per-run dist/verify and local integration output when no longer needed. Never transfer a sealed-origin exception to user-installed copies. Runtime installation versions stay read-only, preserving app signatures and Sparkle delta baselines. Distribution decisions read the current checkout index or explicit LITHE_OFFICIAL_PLUGIN_INDEX/--marketplace-index build input; synthetic packaging indexes are per-test fixtures, never reusable or publishable source indexes." }, { @@ -173,7 +217,11 @@ "/Lithe/PluginMarketplace//index.json", "/Lithe/PluginMarketplace/sources.json", "/lithe-plugin-transfer-", - "/lithe-plugin-download-" + "/lithe-plugin-download-", + "/plugin-marketplace/sources.json", + "/plugin-marketplace/..json", + "/marketplace-download--.zip", + "/plugin-build-options/.json" ], "identity": "Fetched marketplace indexes, the user's plugin repository list, and downloaded or extracted plugin archives are per-user runtime state verified by Rust Core index validation and the index SHA-256; the official index ships read-only from Git-tracked Plugins/marketplace/index.json and has no worktree build stamp", "reason": "Marketplace caches, repository lists and plugin downloads belong to the running app and its user, never to a worktree build. ZIP preflight budgets include unique implicit parent directories and cumulative normalized UTF-8 path bytes before extraction; passing these checks does not create a reusable build identity. Do not copy, symlink or bundle them; each run fetches and verifies its own copy. The bundled index is copied from Git at packaging time only." @@ -410,6 +458,50 @@ ], "identity": "Current worktree shared locale manifest, message registry, per-locale translations and all generated native string-table bytes. Generated outputs have no sealed version/platform/architecture/toolchain reuse identity stamp.", "reason": "Git owns source and checked-in generated outputs. Regenerate and validate them in each target worktree with scripts/generate-i18n.mjs; no download, cache validator or cross-worktree copy stage is permitted. Packaging copies registered native tables into its own build destination before signing and converts only that copy to binary plist. Runtime only reads bundled tables; it creates no state and changes neither code-signature bytes nor the Sparkle delta baseline." + }, + { + "id": "plugin-generator-transactions", + "reusable": false, + "locations": [ + "/..lithe-creating-/", + "//" + ], + "identity": "One native window and generation operation owns the sibling staging directory, process output and published project. User inputs and filesystem/process identity are not a reusable source/platform/architecture/toolchain build stamp.", + "reason": "Never copy staging or user projects through worktree resource reuse. The Windows transaction creates output beside the user-selected target, validates paths/links/budgets and publishes without replacement only after consent. Failed cleanup retains the operation and NativeLease for retry. Installed packages and app resources remain read-only; these user-workspace writes do not alter signatures or update baselines." + }, + { + "id": "plugin-settings-state", + "reusable": false, + "locations": [ + "/plugin-settings/.json", + "/.lithe/plugin-settings.json", + ".json.-.tmp", + ".json.-.backup" + ], + "identity": "Global and workspace plugin preferences and their compare-and-swap snapshots are user-owned runtime state. Replacement temporaries and recovery backups belong to one guarded save, without a transferable version/platform/architecture/toolchain identity stamp.", + "reason": "Never copy settings, transaction temporaries or recovery backups between worktrees. Opening settings only reads the real stores. Confirmed saves preserve unrelated values and permissions, reject stale byte snapshots, atomically replace the selected source and reread it. Delete owned temporaries on failure; preserve the only surviving backup when restoration cannot be proven. The writable app-data/workspace stores do not alter installed resources, signatures or update baselines." + }, + { + "id": "project-generator-transactions", + "reusable": false, + "locations": [ + "/..lithe-creating-/", + "/..lithe-creating-/.lithe-project-archive-.zip", + "/project-generators/{reviews,operations}" + ], + "identity": "One Windows project-generation review and operation, its selected writable parent and untrusted HTTPS archive. No checksum or sealed version/platform/architecture/toolchain identity stamp; neither HTTPS nor Core ZIP validation makes user-selected project content a reusable build resource.", + "reason": "The native transaction owns same-parent exclusive staging and create_new ZIP files. A 128 MiB total redirect/body budget and 120-second fetch deadline share the operation cancellation token; synchronous Core extraction retains the original operation ID and remaining deadline. Core bounds explicit and implicit paths, path bytes and extracted bytes. The ZIP is removed after the synchronous extractor closes; failed/cancelled staging is deleted only after all writers finish. Cleanup failures retain staging and the plugin lease for retry, and an uncertain writer never loses ownership. Atomic no-replace publication transfers the completed directory to the user. Never copy, symlink, bundle or share at any worktree stage; installed resources and Sparkle baselines remain read-only." + }, + { + "id": "swiftpm-build-state", + "reusable": false, + "locations": [ + ".build/", + ".build/.lithe-swift-toolchain", + ".build//.lithe-swift-toolchain" + ], + "identity": "Mutable worktree-owned SwiftPM products, resource bundles, build database and scratch-local compiler identity. The versioned full compiler stamp only controls local invalidation; it is not a sealed source/platform/architecture/SDK/toolchain identity for cross-worktree reuse.", + "reason": "Never copy or share this state at any worktree reuse stage. build-macos.sh cleans the actual selected scratch directory with swift package --scratch-path when its compiler stamp changes or is unknown, retaining that scratch's checkouts and downloaded artifacts. Legacy layouts keep the default scratch. Each architecture owns its stamp; failed cleanup cannot advance it. Rebuild resource bundles before signing instead of mixing old flat resources with new Contents layouts or disabling signatures. Only the separately validated SwiftPM download cache is reusable; installed app bundles remain read-only and the Sparkle delta baseline is unaffected. SwiftPM owns build-state lifetime and cleanup." } ] } diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 00cc79412f..051cf23a16 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -13,6 +13,7 @@ This directory is the cross-platform source of truth: | `plugin-manifest.schema.json` | JSON shape of `plugin.json` | | `fixtures/*.plugin.json` | Valid example manifests, named after the capability they exercise rather than a published plugin: `language-*` (packaged, system-command, and host-integration language servers; `node-runtime` for a discovered runtime), `hello-lithe` and `web-panel` (UI), `settings-v2`, and `build-*` (level 3 build systems and the two forms of a build provider) | | `fixtures/invalid-manifests.json` | Edits to a valid manifest and the rejection code every host must report | +| `fixtures/signature-vectors.json` | Public cross-platform vectors for the package publisher signature: the RFC 8032 test key, a fixed package with non-ASCII file names, its canonical payload, deterministic signature, and content digest. Both platform verifiers must reproduce the same answers | | `fixtures/command-plans.json` | Golden run/test command expansions and language-server launch plans | | `fixtures/runtime-versions.json` | Golden outcomes of the launch runtime version check | | `fixtures/build-plans.json` | Golden build-system plans and decoded outputs | diff --git a/shared/contracts/plugins/v1/fixtures/signature-vectors.json b/shared/contracts/plugins/v1/fixtures/signature-vectors.json new file mode 100644 index 0000000000..4c47bfd36b --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/signature-vectors.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "description": "Public cross-platform test vectors for the plugin package signature (macOS PluginPackageSignature and the Windows plugin_host signature module). The Ed25519 key is the RFC 8032 test key; it must never sign real packages. Regenerating requires updating both platform tests in the same change.", + "ed25519": { + "seedHex": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "publicKeyHex": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + "messageEmptySignatureHex": "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b" + }, + "packageFiles": { + "plugin.json": "plugin manifest bytes", + "language-server/bin/server.js": "console.log('server');\n", + "插件/服务器.js": "console.log('非 ASCII 路径');\n", + "日本語/README.md": "# サンプル\n", + "assets/заметки.txt": "заметки\n" + }, + "canonicalPayload": { + "keyID": "lithe-test-v1", + "pluginID": "dev.example.plugin", + "pluginVersion": "0.3.0", + "files": { + "plugin.json": "1ec2dc0f2dacb3b0c43dd9dc6035731249094d080f60394236f68450420e6a29", + "language-server/bin/server.js": "fca7a5298331fc1e306876075edf8f47b791b200b182c841dc250c54d5138760", + "插件/服务器.js": "481e4738eca92d68a48ef69591c5ecb0afd2529a0eba37b489665cd31b457ad5", + "日本語/README.md": "667ec9c9c320a9dee9f64ce35daf3ec27cf5bc06aaa0625253c788301bd5f7af", + "assets/заметки.txt": "d303fa2a84fb9a7d0c0e50cf1c27ff33f8c90cb53d366138929934f4e2e758fb" + }, + "payloadBase64": "c2NoZW1hVmVyc2lvbj0xCmFsZ29yaXRobT1lZDI1NTE5CmtleUlEPWxpdGhlLXRlc3QtdjEKcGx1Z2luSUQ9WkdWMkxtVjRZVzF3YkdVdWNHeDFaMmx1CnBsdWdpblZlcnNpb249TUM0ekxqQQpmaWxlPVlYTnpaWFJ6TDlDMzBMRFF2TkMxMFlMUXV0QzRMblI0ZEE9ZDMwM2ZhMmE4NGZiOWE3ZDBjMGU1MGNmMWMyN2ZmMzNmOGM5MGNiNTNkMzY2MTM4OTI5OTM0ZjRlMmU3NThmYgpmaWxlPWJHRnVaM1ZoWjJVdGMyVnlkbVZ5TDJKcGJpOXpaWEoyWlhJdWFuTT1mY2E3YTUyOTgzMzFmYzFlMzA2ODc2MDc1ZWRmOGY0N2I3OTFiMjAwYjE4MmM4NDFkYzI1MGM1NGQ1MTM4NzYwCmZpbGU9Y0d4MVoybHVMbXB6YjI0PTFlYzJkYzBmMmRhY2IzYjBjNDNkZDlkYzYwMzU3MzEyNDkwOTRkMDgwZjYwMzk0MjM2ZjY4NDUwNDIwZTZhMjkKZmlsZT01by1TNUx1MkwtYWNqZVdLb2VXWnFDNXFjdz00ODFlNDczOGVjYTkyZDY4YTQ4ZWY2OTU5MWM1ZWNiMGFmZDI1MjlhMGViYTM3YjQ4OTY2NWNkMzFiNDU3YWQ1CmZpbGU9NXBlbDVweXM2S3FlTDFKRlFVUk5SUzV0WkE9NjY3ZWM5YzljMzIwYTlkZWU5ZjY0Y2UzNWRhZjNlYzI3Y2Y1YmMwNmFhYTA2MjUyNTNjNzg4MzAxYmQ1ZjdhZgo=", + "signatureBase64": "lFd79pLu83LDn4D8N0cSAtm3jzHGNLIVGBAfS0v4Wzfnim4DLcCOKITqS8v5iOGP951whGhZIZxqdMZULHy+Cw==" + }, + "contentDigest": { + "files": { + "plugin.json": "1ec2dc0f2dacb3b0c43dd9dc6035731249094d080f60394236f68450420e6a29", + "language-server/bin/server.js": "fca7a5298331fc1e306876075edf8f47b791b200b182c841dc250c54d5138760", + "插件/服务器.js": "481e4738eca92d68a48ef69591c5ecb0afd2529a0eba37b489665cd31b457ad5", + "日本語/README.md": "667ec9c9c320a9dee9f64ce35daf3ec27cf5bc06aaa0625253c788301bd5f7af", + "assets/заметки.txt": "d303fa2a84fb9a7d0c0e50cf1c27ff33f8c90cb53d366138929934f4e2e758fb" + }, + "digest": "4dcdacc3e80f23932029638487a3074301d95a6721151a03a010d8633fe55a61" + } +} diff --git a/shared/fixtures/vcs/windows-root-mappings-v1.json b/shared/fixtures/vcs/windows-root-mappings-v1.json new file mode 100644 index 0000000000..2b5c308910 --- /dev/null +++ b/shared/fixtures/vcs/windows-root-mappings-v1.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "owner": "Lithe Windows host; not SVN or Git configuration", + "mapping": { "id": "mixed", "vcs": "svn" }, + "paths": ["mixed/a", "mixed/nested/b"], + "owners": ["mixed", "mixed/nested"], + "preservedUnknown": { "future": { "keep": true } }, + "rules": { + "openWrites": false, + "saveRequiresCurrentByteRevision": true, + "directoryReplacementInheritsChoice": false, + "truncatedDetectionGrantsCommonOwner": false, + "metadataEventStartsProvider": false, + "maxMappings": 64, + "maxSourceBytes": 1048576, + "maxWatchRoots": 64, + "maxWatchPaths": 256, + "maxDebouncedNativeEvents": 512, + "maxPendingPassiveRefreshes": 1 + } +} diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index c9b7b3b827..0d46fc64f7 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -4595,7 +4595,6 @@ "native.hideBuildOutputToolWindow": "Hide Build Output tool window", "native.hideBuildToolWindow": "Hide Build tool window", "native.hideCommitDecorations": "Hide commit decorations", - "native.hideCommitDetails": "Hide commit details", "native.hideConfigurationList": "Hide configuration list", "native.hideDebugToolWindow": "Hide Debug tool window", "native.hideEntry": "Hide Entry", @@ -6065,7 +6064,6 @@ "native.showingThePreviousJavaEntriesWhileTheJavaLanguageServicePreparesTheProject": "Showing the previous Java entries while the Java language service prepares the project.", "native.shownAcrossTheEntireWorkbenchIncludingToolbarsProjectTreeEditorTabsTerminalsAndStatusBar": "Shown across the entire workbench, including toolbars, project tree, editor, tabs, terminals, and status bar.", "native.sideBySide": "Side-by-side", - "native.sideBySideView": "Side-by-side view", "native.signInToCodex": "Sign in to Codex", "native.signInToGitHub": "Sign in to GitHub", "native.signInToViewAndManagePullRequests": "Sign in to view and manage pull requests.", @@ -7666,15 +7664,8 @@ "settings.mcp.description": "Authorizes this project only. Connected agents can inspect environment, run configurations and output, which may contain sensitive project data. Access ends when the project or IDE closes.", "settings.mcp.disable": "Disable MCP", "settings.mcp.enable": "Enable MCP", - "settings.mcp.error.expired": "The request expired before execution. Check operation status before retrying.", - "settings.mcp.error.request": "The tool request failed. See the MCP client for the diagnostic details.", - "settings.mcp.error.transport": "Request delivery failed. Access remains enabled; the operation will not be replayed.", - "settings.mcp.error.undelivered": "The client no longer accepts this result. Check operation status before retrying.", "settings.mcp.openProject": "Open a project to configure MCP access for agents.", "settings.mcp.plugins": "Enabled plugins declaring IDE access can also use this project grant.", - "settings.mcp.status.active": "Last tool call succeeded", - "settings.mcp.status.failed": "Last request failed · access remains enabled", - "settings.mcp.status.waiting": "Authorized · waiting for a tool call", "settings.mcp.title": "MCP Configuration", "settings.navigation": "Settings navigation", "settings.noMatching": "No settings found", @@ -9020,20 +9011,15 @@ "native.theAgentDidNotAcceptTheRequestIt": "The Agent did not accept the request. It may have stopped.", "native.savedBreakpointsCouldNotBeRead": "Saved breakpoints could not be read.", "native.savedDebuggerSteppingFiltersCouldNotBeRead": "Saved debugger stepping filters could not be read.", - "native.theLanguageServiceThatHostsTheDebugAdapterIsUnavailable": "The language service that hosts the debug adapter is unavailable.", "native.theDebugAdapterNeedsWhichWasNotFoundInstall": "The debug adapter {arg1} needs {arg2}, which was not found. Install it, then try again.", "native.theDebugAdapterMustLaunchAnExecutableFromItsPluginPackage": "The debug adapter {arg1} must launch an executable from its plugin package.", "native.thePluginDoesNotProvideTheDebugAdapter": "The plugin does not provide the debug adapter {arg1}.", - "native.theDebugAdapterNamesNoLanguageServerToHostIt": "The debug adapter {arg1} names no language server to host it.", "native.theDebugAdapterUsesTheTransportWhichLitheCannotStartYet": "The debug adapter {arg1} uses the {arg2} transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported.", "native.theDebugAdapterMustLaunchAPackagedExecutableOrASystemCommand": "The debug adapter {arg1} must launch a packaged executable or a system command.", - "native.couldNotConnectToTheDebugAdapter": "Could not connect to the Debug Adapter: {arg1}", - "native.theDebugAdapterConnectionIsStopped": "The debug adapter connection is stopped.", - "native.theLanguageServerReturnedAnInvalidTcpPortForItsDebugAdapter": "The {arg1} language server returned an invalid TCP port for its debug adapter.", - "native.theLanguageServerOfThisPluginIsNotAvailable": "The {arg1} language server of this plugin is not available.", "native.couldNotStartTheJavaTestResultListener": "Could not start the Java test result listener: {arg1}", "native.theJavaTestResultListenerDidNotBecome": "The Java test result listener did not become ready in time.", "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.couldNotConnectToTheDebugAdapter": "Could not connect to the Debug Adapter: {arg1}", "native.dittoExitedWithCodeLld": "ditto exited with code {arg1}: {arg2}", "native.githubProducedAnInvalidRequest": "GitHub produced an invalid request", "native.githubReturnedAnInvalidHttpResponse": "GitHub returned an invalid HTTP response", @@ -9381,25 +9367,454 @@ "native.size": "Size", "native.thisFolderCannotBeRead": "This folder cannot be read.", "native.thisFolderIsEmpty": "This folder is empty", - "debug.tcp.startupTimedOut": "The debug adapter connection timed out.", - "debug.tcp.writeTimedOut": "The debug adapter did not accept outgoing data in time.", - "debug.tcp.bufferFull": "The debug adapter outgoing buffer is full.", - "debug.tcp.invalidEndpoint": "Enter a valid debug adapter host and a port between 1 and 65535.", - "debug.tcp.declined": "The debug adapter connection was not approved.", - "debug.tcp.confirmTitle": "Connect to an external debug adapter?", - "debug.tcp.confirmBody": "The debug adapter at {arg1} can access your debugging data. Plain TCP is not encrypted or authenticated. Connect only over a trusted network or a tunnel you configured.", - "debug.tcp.menu": "Connect to Debug Adapter…", - "debug.tcp.title": "Connect to Debug Adapter", - "debug.tcp.name": "External Debug Adapter", - "debug.tcp.help": "Connect to an already running DAP server using the active file's installed debugger. This is not a JVM JDWP or gdbserver port. No proxy or tunnel is created.", - "debug.tcp.request": "Request", - "debug.tcp.arguments": "Adapter Arguments (JSON)", - "debug.tcp.argumentsHelp": "Enter the launch or attach arguments required by this adapter, including its source path mappings if needed. These are separate from the DAP server's TCP address.", - "debug.tcp.invalidArguments": "Enter a JSON object of adapter arguments no larger than 256 KiB.", - "debug.tcp.attachRequest": "Attach Request", - "debug.tcp.launchRequest": "Launch Request", - "debug.tcp.sessionActive": "Stop the current debug session before connecting to a debug adapter.", - "debug.tcp.notStarted": "The debug adapter connection could not be started.", + "settings.declarativePlugins.sources.title": "Plugin sources", + "settings.declarativePlugins.sources.description": "Sources of the declarative plugin marketplace. Preset sources are rebuilt by the host; the official source can be disabled but not removed.", + "settings.declarativePlugins.sources.add": "Add source", + "settings.declarativePlugins.sources.addPlaceholder": "https://github.com/owner/repo or an index URL", + "settings.declarativePlugins.sources.empty": "No third-party sources added.", + "settings.declarativePlugins.sources.official": "Official", + "settings.declarativePlugins.sources.thirdParty": "Third-party", + "settings.declarativePlugins.sources.remove": "Remove", + "settings.declarativePlugins.sources.toggle": "Enable or disable source {name}", + "settings.declarativePlugins.sources.errorAlreadyAdded": "That source has already been added.", + "settings.declarativePlugins.sources.errorOfficialCannotBeRemoved": "The official source cannot be removed.", + "settings.declarativePlugins.sources.errorNotFound": "That source no longer exists.", + "settings.declarativePlugins.sources.errorInvalidInput": "Enter an https URL of a plugin index or a GitHub repository.", + "settings.declarativePlugins.sources.errorLoad": "The source list could not be loaded.", + "settings.declarativePlugins.sources.errorGeneric": "The source could not be updated. Try again.", + "settings.declarativePlugins.sources.corruptNotice": "The saved source list was unreadable. The original file was preserved next to the store and the list was rebuilt from the presets.", + "settings.declarativePlugins.sources.retry": "Retry", + "buildSystems.run.title": "Tasks and tests", + "buildSystems.run.action": "Action", + "buildSystems.run.chooseAction": "Choose an action…", + "buildSystems.run.task": "Task", + "buildSystems.run.tests": "Tests", + "buildSystems.run.scope": "Scope", + "buildSystems.run.scope.workspace": "Workspace", + "buildSystems.run.scope.project": "Project", + "buildSystems.run.scope.target": "Target", + "buildSystems.run.project": "Project", + "buildSystems.run.chooseProject": "Choose a project…", + "buildSystems.run.target": "Target", + "buildSystems.run.chooseTarget": "Choose a target…", + "buildSystems.run.modelTask": "Model task", + "buildSystems.run.chooseTask": "Choose a model task…", + "buildSystems.run.suite": "Test suite filter", + "buildSystems.run.case": "Test case filter", + "buildSystems.run.arguments": "Arguments (one literal argument per line)", + "buildSystems.run.prepare": "Prepare task / test…", + "buildSystems.run.cancel": "Cancel task / test", + "buildSystems.run.review": "Review task / test execution", + "buildSystems.run.approve": "Allow and execute", + "buildSystems.run.programs": "Native-reviewed program declarations are shown below. These are templates; native resolves the executable after approval.", + "buildSystems.run.failed": "Task or test operation failed: {message}", + "buildSystems.run.retryDefinitions": "Reload task declarations", + "buildSystems.run.modelReloadRequired": "The model is missing or stale. Use Load / Reload model above (or Reload for a static model), then retry this action.", + "buildSystems.run.phase.preparing": "Saving and synchronizing before review…", + "buildSystems.run.phase.review": "Waiting for your decision", + "buildSystems.run.phase.starting": "Subscribing before execution…", + "buildSystems.run.phase.running": "Task / test operation active", + "buildSystems.run.phase.stopping": "Cancelling; waiting for native cleanup…", + "buildSystems.run.phase.cancelFailed": "Cancellation needs attention; retry Stop", + "buildSystems.run.phase.failed": "Task / test preparation failed", + "buildSystems.run.phase.ended": "Task / test operation ended", + "buildSystems.model.title": "Build model", + "buildSystems.model.load": "Load model…", + "buildSystems.model.reload": "Reload model…", + "buildSystems.model.cancel": "Cancel model loading", + "buildSystems.model.retryCancel": "Retry cancellation", + "buildSystems.model.reviewTitle": "Review model loading", + "buildSystems.model.approve": "Allow and load", + "buildSystems.model.decline": "Decline", + "buildSystems.model.plugin": "Plugin", + "buildSystems.model.buildSystem": "Build system", + "buildSystems.model.workspace": "Workspace", + "buildSystems.model.instance": "Instance", + "buildSystems.model.operation": "Operation", + "buildSystems.model.generation": "Generation", + "buildSystems.model.projectCode": "This operation may execute code from the project.", + "buildSystems.model.noProjectCode": "The prepared plan does not declare project code execution.", + "buildSystems.model.network": "This operation may access the network.", + "buildSystems.model.noNetwork": "The prepared plan does not declare network access.", + "buildSystems.model.failed": "Model loading failed: {message}. Check the tool or project configuration, then retry.", + "buildSystems.model.cancelFailed": "Cancellation failed: {message}. Retry cancellation before starting another load.", + "buildSystems.model.stderr": "Model loading stderr", + "buildSystems.model.phase.preparing": "Preparing model review…", + "buildSystems.model.phase.review": "Waiting for your decision", + "buildSystems.model.phase.loading": "Loading model…", + "buildSystems.model.phase.stopping": "Cancelling; waiting for operation cleanup…", + "buildSystems.model.phase.ready": "Model loaded", + "buildSystems.model.phase.declined": "Model loading declined", + "buildSystems.model.phase.stopped": "Model operation ended without applying a result", + "buildSystems.model.phase.failed": "Model loading failed", + "buildSystems.model.phase.cancelFailed": "Cancellation needs attention", + "buildSystems.close": "Close", + "buildSystems.refresh": "Reload", + "buildSystems.manifestRejected": "{plugin}: {message}", + "buildSystems.shadowed": "{plugin} is shadowed by {by}", + "buildSystems.detectFailed": "Build detection failed: {message}", + "buildSystems.loading": "Detecting build systems…", + "buildSystems.emptyState": "No {name} project detected in this workspace.", + "buildSystems.instanceSelector": "Instance", + "buildSystems.modelError": "Model error: {message}", + "buildSystems.staleBanner": "The project model is out of date. Reload to pick up the changes.", + "buildSystems.optionsTitle": "Options", + "buildSystems.projectsTitle": "Projects", + "buildSystems.fromModel": "from model", + "buildSystems.reset": "Reset", + "buildSystems.resetOption": "Reset {name}", + "buildSystems.railLabel": "{name} build window", + "settings.declarativePluginDetails.noSelection": "Select a plugin to view its details.", + "settings.declarativePluginDetails.loading": "Loading plugin details…", + "settings.declarativePluginDetails.retry": "Retry", + "settings.declarativePluginDetails.tabs": "Plugin detail sections", + "settings.declarativePluginDetails.tab.overview": "Overview", + "settings.declarativePluginDetails.tab.features": "Features", + "settings.declarativePluginDetails.tab.settings": "Settings", + "settings.declarativePluginDetails.tab.permissions": "Permissions & Programs", + "settings.declarativePluginDetails.tab.versions": "Versions & Source", + "settings.declarativePluginDetails.notice.diskCommunity": "Installed from disk. The publisher and repository are not authenticated; Lithe verifies the exact bytes you approved.", + "settings.declarativePluginDetails.notice.community": "Community package. Its source is shown for context and does not by itself prove an official publisher.", + "settings.declarativePluginDetails.action.enable": "Enable", + "settings.declarativePluginDetails.action.disable": "Disable", + "settings.declarativePluginDetails.action.rollback": "Roll back…", + "settings.declarativePluginDetails.action.uninstall": "Uninstall", + "settings.declarativePluginDetails.field.status": "Status", + "settings.declarativePluginDetails.field.provides": "Provides", + "settings.declarativePluginDetails.field.source": "Source", + "settings.declarativePluginDetails.field.settings": "Settings", + "settings.declarativePluginDetails.field.update": "Updates", + "settings.declarativePluginDetails.field.pluginId": "Plugin ID", + "settings.declarativePluginDetails.field.version": "Version", + "settings.declarativePluginDetails.field.verification": "Verification", + "settings.declarativePluginDetails.field.repository": "Repository", + "settings.declarativePluginDetails.field.archiveDigest": "Archive SHA-256", + "settings.declarativePluginDetails.field.contentDigest": "Content SHA-256", + "settings.declarativePluginDetails.status.quarantined": "Quarantined", + "settings.declarativePluginDetails.status.enabled": "Enabled", + "settings.declarativePluginDetails.status.disabled": "Disabled", + "settings.declarativePluginDetails.empty.features": "This plugin declares no features.", + "settings.declarativePluginDetails.empty.settings": "This plugin declares no settings.", + "settings.declarativePluginDetails.empty.permissions": "This plugin requests no permissions.", + "settings.declarativePluginDetails.empty.programs": "This plugin starts no programs.", + "settings.declarativePluginDetails.settingsDisabled": "Enable this accepted plugin to edit its settings.", + "settings.declarativePluginDetails.availability.available": "Available on Windows", + "settings.declarativePluginDetails.availability.declaredOnly": "Declared, not currently hosted on Windows", + "settings.declarativePluginDetails.permissions": "Permissions", + "settings.declarativePluginDetails.programs": "Programs this plugin can start", + "settings.declarativePluginDetails.program": "Complete program declaration {index}", + "settings.declarativePluginDetails.permission.backend.launch": "Start the plugin's background service", + "settings.declarativePluginDetails.permission.credentials.store": "Store credentials through Lithe", + "settings.declarativePluginDetails.permission.languageServer.launch": "Start a language server", + "settings.declarativePluginDetails.permission.process.build": "Run build tools", + "settings.declarativePluginDetails.permission.process.run": "Run project programs", + "settings.declarativePluginDetails.permission.process.test": "Run project tests", + "settings.declarativePluginDetails.permission.process.vcs": "Run version-control tools", + "settings.declarativePluginDetails.permission.project.generate": "Generate project files", + "settings.declarativePluginDetails.permission.ui.contribute": "Add commands and tool windows", + "settings.declarativePluginDetails.permission.ui.web": "Show plugin-provided web content", + "settings.declarativePluginDetails.currentVersion": "Current version", + "settings.declarativePluginDetails.previousVersion": "Previous version", + "settings.declarativePluginDetails.update.unknown": "Marketplace update data is not connected yet.", + "settings.declarativePluginDetails.source.bundled": "Bundled with Lithe", + "settings.declarativePluginDetails.source.marketplace": "Marketplace", + "settings.declarativePluginDetails.source.disk": "Local disk package", + "settings.declarativePluginDetails.source.unknown": "Unknown", + "settings.declarativePluginDetails.verification.publisherSignature": "Publisher signature verified", + "settings.declarativePluginDetails.verification.hostSeal": "Verified as part of the Lithe installation", + "settings.declarativePluginDetails.verification.approvedDigest": "Matches the bytes approved during installation", + "settings.declarativePluginDetails.verification.rejected": "Verification failed", + "settings.declarativePluginDetails.verification.unknown": "Not verified yet", + "settings.declarativePluginList.search": "Search name or ID; / for filters", + "settings.declarativePluginList.filters": "Filters", + "settings.declarativePluginList.reset": "Clear filters", + "settings.declarativePluginList.refresh": "Refresh plugins", + "settings.declarativePluginList.loading": "Refreshing plugins…", + "settings.declarativePluginList.capability": "Contribution", + "settings.declarativePluginList.capability.all": "All contributions", + "settings.declarativePluginList.capability.language": "Language contribution", + "settings.declarativePluginList.state": "Status", + "settings.declarativePluginList.state.all": "All statuses", + "settings.declarativePluginList.state.enabled": "Enabled", + "settings.declarativePluginList.state.disabled": "Disabled", + "settings.declarativePluginList.state.quarantined": "Quarantined", + "settings.declarativePluginList.source": "Package location", + "settings.declarativePluginList.source.all": "All locations", + "settings.declarativePluginList.unavailableFilters": "Update, publisher and build filters are not available yet.", + "settings.declarativePluginList.count": "Showing {count} of {total} plugins", + "settings.declarativePluginList.selectionHidden": "The selected plugin is hidden by the current filters.", + "settings.declarativePluginList.showSelection": "Show selected plugin", + "settings.declarativePluginList.noMatches": "No plugins match these filters.", + "settings.declarativePlugins.marketplace.title": "Marketplace", + "settings.declarativePlugins.marketplace.description": "Browse and install plugins from your enabled plugin sources.", + "settings.declarativePlugins.marketplace.searchPlaceholder": "Search plugins", + "settings.declarativePlugins.marketplace.allSources": "All sources", + "settings.declarativePlugins.marketplace.refresh": "Refresh", + "settings.declarativePlugins.marketplace.refreshing": "Refreshing…", + "settings.declarativePlugins.marketplace.loading": "Loading the marketplace…", + "settings.declarativePlugins.marketplace.loadFailed": "The marketplace could not be loaded.", + "settings.declarativePlugins.marketplace.retry": "Retry", + "settings.declarativePlugins.marketplace.installedWarning": "Installed plugins could not be read; install states may be incomplete.", + "settings.declarativePlugins.marketplace.notConnected": "The marketplace backend is not connected in this build.", + "settings.declarativePlugins.marketplace.empty": "No plugins match.", + "settings.declarativePlugins.marketplace.noSources": "No enabled marketplace sources.", + "settings.declarativePlugins.marketplace.rejectedCount": "Ignored {count} invalid entries.", + "settings.declarativePlugins.marketplace.cacheProblem": "The cached index could not be read.", + "settings.declarativePlugins.marketplace.refreshFailed": "The source could not be refreshed.", + "settings.declarativePlugins.marketplace.install": "Install", + "settings.declarativePlugins.marketplace.update": "Update", + "settings.declarativePlugins.marketplace.preparing": "Preparing the package…", + "settings.declarativePlugins.marketplace.installing": "Installing…", + "settings.declarativePlugins.marketplace.awaitingConfirmation": "Waiting for your approval.", + "settings.declarativePlugins.marketplace.errorFallback": "The operation failed.", + "settings.declarativePlugins.marketplace.state.installed": "Installed {version}", + "settings.declarativePlugins.marketplace.state.updateAvailable": "Update available: {installed} → {available}", + "settings.declarativePlugins.marketplace.state.fromOtherSource": "Installed from {source}; updates come from that source only.", + "settings.declarativePlugins.marketplace.state.managedOutside": "Installed from disk or bundled; not managed by the marketplace.", + "settings.declarativePlugins.marketplace.state.unsupported.platform": "No build for {platform}.", + "settings.declarativePlugins.marketplace.state.unsupported.api": "Requires a newer version of Lithe.", + "settings.declarativePlugins.marketplace.state.unsupported.unknown": "Not available: {message}", + "settings.declarativePlugins.marketplace.review.title": "Install {name} {version}?", + "settings.declarativePlugins.marketplace.review.source": "Source: {source}", + "settings.declarativePlugins.marketplace.review.officialVerified": "Official publisher verified.", + "settings.declarativePlugins.marketplace.review.unverifiedNotice": "This source and its publisher are not verified by Lithe.", + "settings.declarativePlugins.marketplace.review.notice": "Review the permissions and full program declarations before installing.", + "settings.declarativePlugins.marketplace.review.permissions": "Permissions", + "settings.declarativePlugins.marketplace.review.newPrograms": "{count} new program(s) need your approval.", + "settings.declarativePlugins.marketplace.error.cancelled": "The operation was cancelled.", + "settings.declarativePlugins.marketplace.error.sourceUnavailable": "The marketplace source was removed or disabled.", + "settings.declarativePlugins.marketplace.error.indexChanged": "The source index changed. Start the installation again.", + "settings.declarativePlugins.marketplace.error.approvalRequired": "Explicit approval is required.", + "settings.declarativePlugins.marketplace.error.digestMismatch": "The package changed after the review.", + "settings.declarativePlugins.marketplace.error.tooManyOperations": "Too many pending marketplace operations. Try again later.", + "buildOutput.title": "Build Output", + "buildOutput.emptyState": "Run a build task to see output.", + "buildOutput.logLabel": "Build output log", + "buildOutput.truncatedLines": "… {count} earlier lines truncated", + "buildOutput.status.succeeded": "Succeeded", + "buildOutput.status.failed": "Failed", + "buildOutput.status.cancelled": "Cancelled", + "buildOutput.status.failedToStart": "Failed to start", + "buildOutput.stopping": "Stopping…", + "buildOutput.stopLabel": "Stop build task", + "buildOutput.stopFailed": "Stopping the run failed: {message}", + "buildOutput.clearLabel": "Clear build output", + "buildOutput.close": "Close", + "buildOutput.exitCode": "Exit code {code}", + "buildOutput.testsPassed": "Tests passed", + "buildOutput.testsFailed": "Tests failed", + "buildOutput.testsPassedCount": "{count} passed", + "buildOutput.testsFailedCount": "{count} failed", + "buildOutput.testsSkippedCount": "{count} skipped", + "buildOutput.moreProblems": "… {count} more", + "buildOutput.diagnosticsTruncated": "… {count} diagnostics truncated", + "buildOutput.subscriptionFailed": "Output subscription failed: {message}", + "buildOutput.testsTruncated": "{count} test details omitted", + "buildSystems.dependencies.title": "Dependencies", + "buildSystems.dependencies.selection": "Dependency project", + "buildSystems.dependencies.instance": "Instance root", + "buildSystems.dependencies.load": "Load dependencies…", + "buildSystems.dependencies.review": "Review dependency loading", + "buildSystems.dependencies.approve": "Approve and load", + "buildSystems.dependencies.tree": "Dependency tree", + "buildSystems.dependencies.empty": "No dependencies reported.", + "buildSystems.dependencies.failed": "Dependency loading failed: {message}", + "buildSystems.dependencies.stale": "Dependencies changed. Load them again.", + "buildSystems.dependencies.more": "Show more dependencies", + "buildSystems.dependencies.truncated": "Core reached the dependency tree limit. Some nodes are omitted.", + "buildSystems.dependencies.providerUnavailable": "Provider dependencies are not available yet.", + "plugins.execution.reviewTitle": "Run plugin command?", + "plugins.execution.failed": "Cannot run this plugin selection ({code}). Save files and refresh the actions, or choose a supported scope.", + "plugins.execution.review": "Workspace: {workspace}\nSelection: {selection}\nCommand: {command}", + "buildSystems.provider.programs": "Provider programs approved for this request", + "buildSystems.provider.shared": "Use the existing {language} language server", + "plugin.build.providerNotNegotiated": "The server did not negotiate build Provider support.", + "plugin.build.providerFailed": "The build Provider could not complete this request. Reload to retry.", + "generator.title": "Plugin project generators", + "generator.description": "Create a project from an installed plugin.", + "generator.plugins": "Plugins", + "generator.back": "Back", + "generator.search": "Search generators", + "generator.empty": "No project generators are available for this host.", + "generator.projects": "New project", + "generator.generators": "Generators", + "generator.git": "Create Git repository", + "generator.advanced": "Advanced fields", + "generator.invalidField": "Check this field and its required format or runtime version.", + "generator.chooseRuntime": "Choose runtime folder", + "generator.requirements": "Language or build support is not enabled: {names}. You can still create this project.", + "generator.review": "Review creation", + "generator.network": "This generator may access the network.", + "generator.steps": "Generation steps: {count}", + "generator.create": "Create project", + "generator.edit": "Edit form", + "generator.cancel": "Cancel generation", + "generator.openCreated": "Open created project", + "generator.archiveUnavailable": "Archive generators are unavailable until restricted downloads and bounded extraction are supported.", + "generator.cleanupFailed": "Cleanup could not be confirmed. The staging directory and plugin lock were retained; check running processes before retrying.", + "generator.destinationExists": "The destination already exists. Choose another name or location.", + "generator.cancelled": "Project generation was cancelled.", + "generator.timeout": "Project generation exceeded its time limit.", + "generator.toolMissing": "A required command is missing. Install it and ensure it is available on PATH.", + "generator.stale": "The reviewed inputs changed or expired. Review creation again.", + "generator.runtimeInvalid": "The selected folder does not contain a supported runtime.", + "generator.batchArgumentUnsupported": "This command uses a batch shim with unsupported shell characters. Choose inputs without those characters or use a native executable.", + "generator.failed": "The project could not be created. Check the generator inputs, tools, and folder permissions.", + "plugins.settings.actionUnavailable": "This action is unavailable because the plugin provider function connection is not supported yet.", + "plugins.settings.effect.immediate": "Saved and sent to the active language server.", + "plugins.settings.effect.nextRun": "Saved. Takes effect on the next run.", + "plugins.settings.effect.nextStart": "Saved. No active language server received this configuration; it will be used at the next start.", + "plugins.settings.effect.reloadUnavailable": "Saved. Project reload is unavailable; this change has not been applied to the current project.", + "plugins.settings.effect.restart": "Saved. Takes effect when the language server next starts.", + "plugins.settings.failed": "Plugin settings unavailable: {code}. Reload settings before trying again.", + "plugins.settings.inherit": "Use inherited value", + "plugins.settings.reload": "Reload settings and discard drafts", + "plugins.settings.storage": "Save scope", + "plugins.settings.storageInfo": "These plugin preferences are the values Lithe supplies to plugin launch and workspace configuration. Save writes only the selected scope at the displayed path.", + "plugins.settings.projectOverride": "Overridden in this workspace", + "plugins.settings.recoveryRequired": "Saving stopped because a recovery backup remains beside the settings file. Review the original .backup file and restore or remove it before editing again.", + "vcs.roots.sources": "Plugin version control roots", + "vcs.roots.passive": "Only read-only status is available for this root.", + "vcs.roots.ambiguous": "Version control is ambiguous for {path}", + "vcs.roots.incomplete": "Root detection is incomplete", + "vcs.roots.ownerUnavailable": "File ownership is unavailable", + "vcs.roots.fileOwner": "Current file: {vcs} · {path}", + "vcs.roots.noOwner": "Current file has no unambiguous version control owner", + "vcs.roots.checkingOwner": "Checking current file ownership…", + "generator.downloadFailed": "The project archive could not be downloaded. Check the HTTPS server address and network connection.", + "generator.archiveInvalid": "The project archive is invalid or contains unsafe paths or links. Check the generator server.", + "generator.outputLimit": "Project generation exceeded its download or output limit. Choose a smaller template or check the generator.", + "plugins.settings.reloadPlugin": "Reload plugin in all workspaces", + "plugins.settings.reloadPluginInfo": "Stops this plugin’s current operations in every window and reloads its accepted package and saved settings. Language servers start again when requested. Other plugins are unaffected.", + "plugins.settings.reloadingPlugin": "Reloading plugin…", + "plugins.settings.reloadLater": "Later", + "plugins.settings.effect.reloadRequired": "Saved. Confirm reload to apply this change to the plugin.", + "plugins.settings.reloadProjectInfo": "Reloads this plugin’s build models in the saved project window. Command and Provider models ask for approval before running. Other windows keep running.", + "plugins.settings.effect.projectReloadUnavailable": "Saved. Workspace-only reload is unavailable; the change will apply on the next plugin start in this workspace.", + "vcs.status.refresh": "Read status", + "vcs.status.stop": "Stop", + "vcs.status.retryStop": "Retry cleanup", + "vcs.status.loading": "Reading changes…", + "vcs.status.stopping": "Stopping the version control provider…", + "vcs.status.cleanupFailed": "Provider cleanup could not be confirmed. Retry before reading status again.", + "vcs.status.unavailable": "Version control status is unavailable. Refresh roots or check the plugin.", + "vcs.status.toolUnavailable": "Install the version control client required by this plugin, then retry.", + "vcs.status.timeout": "Reading status timed out. Retry after cleanup completes.", + "vcs.status.empty": "No changes in this workspace root", + "vcs.status.changes": "Read-only version control changes", + "vcs.status.truncated": "The provider returned an incomplete change list", + "vcs.status.kind.added": "Added", + "vcs.status.kind.conflicted": "Conflicted", + "vcs.status.kind.copied": "Copied", + "vcs.status.kind.deleted": "Deleted", + "vcs.status.kind.ignored": "Ignored", + "vcs.status.kind.missing": "Missing", + "vcs.status.kind.modified": "Modified", + "vcs.status.kind.renamed": "Renamed", + "vcs.status.kind.replaced": "Replaced", + "vcs.status.kind.switched": "Switched", + "vcs.status.kind.typeChanged": "Type changed", + "vcs.status.kind.unversioned": "Unversioned", + "vcs.status.visibleLimit": "Showing {count} of {total} changes", + "generator.disabled": "Disabled", + "generator.enableOwner": "Enable this generator's plugin before creating a project.", + "generator.enablePlugin": "Enable {name}", + "generator.findPlugin": "Find {name} in plugin marketplace", + "generator.advisoryRequirements": "These language or build plugins are optional for creating this project.", + "generator.pluginUnavailable": "The plugin is unavailable or changed. Refresh the generators.", + "generator.pluginEnableFailed": "The enable request did not complete. Check the refreshed plugin state before retrying.", + "generator.refresh": "Refresh generators", + "plugins.functions.newAttempt": "Run again", + "plugins.functions.retryCleanup": "Retry Stop", + "plugins.functions.review": "Review plugin process and workspace access", + "plugins.functions.confirmAction": "This action requires confirmation. Review the process and access before continuing.", + "plugins.functions.workspace": "Workspace", + "plugins.functions.function": "Plugin function", + "plugins.functions.projectCode": "May execute project code", + "plugins.functions.network": "May use the network", + "plugins.functions.confirm": "Confirm and run", + "plugins.functions.failed": "Plugin action failed: {code}.", + "plugins.functions.yes": "Yes", + "plugins.functions.no": "No", + "plugins.functions.phase.preparing": "Preparing action review…", + "plugins.functions.phase.review": "Waiting for confirmation", + "plugins.functions.phase.starting": "Starting plugin process…", + "plugins.functions.phase.running": "Plugin action is running…", + "plugins.functions.phase.stopping": "Stopping the owned action…", + "plugins.functions.phase.cleanupFailed": "Stop could not be confirmed. The original owner is retained; retry Stop.", + "plugins.functions.phase.succeeded": "Plugin action completed", + "plugins.functions.phase.failed": "Plugin action failed", + "plugins.functions.phase.cancelled": "Plugin action cancelled", + "vcs.mapping.source": "Lithe workspace mappings · {path}", + "vcs.mapping.sourceInfo": "Lithe stores these root choices in application data. Saving does not change SVN or Git configuration.", + "vcs.mapping.select": "Version control for {path}", + "vcs.mapping.automatic": "Require a choice when ambiguous", + "vcs.mapping.save": "Save mapping", + "vcs.mapping.cancel": "Discard draft", + "vcs.mapping.conflict": "The mapping source or root changed. Your draft is kept; discard it and choose again from the refreshed source.", + "vcs.mapping.failed": "The mapping could not be saved. Your draft is kept. Check that the Lithe source is writable and refresh roots before retrying.", + "vcs.watch.failed": "Automatic root monitoring is unavailable or exceeded its limit. Refresh roots manually; status still requires Read status.", + "vcs.roots.unavailable": "Unable to read version control roots or Lithe mappings. Retry after cleanup or check the mapping source.", + "vcs.roots.refresh": "Refresh roots", + "pluginNative.title": "Windows native plugin panel", + "pluginNative.placementUnavailable": "This native panel placement is unavailable on Windows.", + "pluginNative.unavailable": "Plugin functions are currently unavailable for this panel.", + "pluginNative.reset": "Reset native panel state", + "pluginNative.close": "Close native plugin panel", + "pluginNative.loading": "Loading native plugin panel", + "pluginNative.stateLimit": "The native panel state is too large.", + "pluginNative.failed": "The native plugin panel request failed.", + "pluginNative.cancelled": "The native plugin panel request was cancelled.", + "pluginNative.timeout": "The native plugin panel request timed out.", + "pluginNative.invalidData": "The plugin returned invalid native panel data.", + "pluginNative.info": "Information for {name}", + "pluginNative.empty": "No items", + "pluginNative.collapse": "Collapse {name}", + "pluginNative.expand": "Expand {name}", + "vcs.history.read": "Read history", + "vcs.history.compareBase": "Compare with baseline", + "vcs.history.readFile": "Read file history", + "vcs.history.loading": "Reading history…", + "vcs.history.stopping": "Stopping the version control provider…", + "vcs.history.cleanupFailed": "Provider cleanup could not be confirmed. Retry cleanup before reading history again.", + "vcs.history.failed": "Version control history is unavailable. Refresh roots or check the plugin.", + "vcs.history.entries": "Read-only version control history", + "vcs.history.noMessage": "(no message)", + "vcs.history.more": "Read more", + "vcs.history.limit": "History display reached its safety limit", + "vcs.history.empty": "No history entries", + "vcs.history.compareTitle": "{file}: baseline comparison", + "vcs.actions.checkin": "Commit selected changes", + "vcs.actions.checkinRemote": "Commit selected changes to server", + "vcs.actions.rollback": "Revert selected changes", + "vcs.actions.update": "Update working copy", + "vcs.actions.message": "Commit message", + "vcs.actions.cancel": "Cancel", + "vcs.actions.confirm": "Confirm and run", + "vcs.actions.count": "{count} paths affected", + "vcs.actions.running": "VCS operation in progress. Stopping may leave changes applied.", + "vcs.actions.completed": "VCS operation completed.", + "vcs.actions.unknown": "The operation may have changed the working copy or server. Refresh and verify the result before trying again.", + "vcs.actions.conflicts": " {count} paths have conflicts.", + "vcs.actions.risk.checkin": "Only the listed paths are submitted, including both ends of moves. Added or missing files may be recorded. A provider without a publishing step commits directly to the server.", + "vcs.actions.risk.rollback": "Local changes at the listed paths will be discarded. Reverting an added file may remove it. This cannot be undone here.", + "vcs.actions.risk.update": "This updates the entire displayed working copy, including clean files, and may contact the server or create conflicts.", + "vcs.actions.error.vcs.authentication.failed": "Authentication failed. Check the VCS client credentials.", + "vcs.actions.error.vcs.conflict": "The provider reported conflicts. Refresh and resolve them.", + "vcs.actions.error.vcs.outOfDate": "The working copy is out of date. Refresh and update it.", + "vcs.actions.error.vcs.tool.unavailable": "The VCS client is unavailable. Check its installation.", + "vcs.actions.error.vcs.workingCopy.locked": "The working copy is locked. Check it in the VCS client.", + "vcs.actions.error.vcs.workingCopy.unsupported": "The working copy format is unsupported.", + "vcs.actions.error.vcs.capability.unsupported": "This provider did not negotiate the requested action.", + "vcs.actions.error.vcs.provider.pathUnavailable": "The selected scope cannot be safely changed. Select individual files within one unambiguous root; update requires the whole working copy.", + "vcs.actions.error.requestTimeout": "The operation timed out. Check the result before retrying.", + "vcs.actions.error.requestCancelled": "The operation was cancelled. Check the result before retrying.", + "vcs.actions.wholeCopy": "Entire working copy (all files)", + "native.theLanguageServiceThatHostsTheDebugAdapterIsUnavailable": "The language service that hosts the debug adapter is unavailable.", + "native.theDebugAdapterNamesNoLanguageServerToHostIt": "The debug adapter {arg1} names no language server to host it.", + "native.theDebugAdapterConnectionIsStopped": "The debug adapter connection is stopped.", + "native.theLanguageServerReturnedAnInvalidTcpPortForItsDebugAdapter": "The {arg1} language server returned an invalid TCP port for its debug adapter.", + "native.theLanguageServerOfThisPluginIsNotAvailable": "The {arg1} language server of this plugin is not available.", "debugger.disassembly.title": "Disassembly", "debugger.disassembly.steppingByInstruction": "Stepping by instruction", "debugger.disassembly.loading": "Loading instructions…", @@ -9448,6 +9863,42 @@ "debugger.step.overInstruction": "Step Over Instruction", "debugger.step.intoInstruction": "Step Into Instruction", "debugger.disassembly.stepsByLine": "This adapter cannot step by instruction; steps still move by line", + "vcs.actions.resolve": "Mark as Resolved", + "vcs.actions.cleanup": "Clean Up Working Copy", + "vcs.actions.risk.resolve": "These conflict files will be marked as resolved using their current saved contents. Review every listed file before continuing; this changes working copy conflict metadata.", + "vcs.actions.risk.cleanup": "This asks the provider to clean up the entire displayed working copy, including paths with no status changes. It may change working copy metadata and locks; wait for it to finish before running another VCS client.", + "vcs.actions.add": "Add selected files to version control", + "vcs.actions.delete": "Delete selected files through version control", + "vcs.actions.risk.add": "Only existing ordinary files directly in this root can be added. Directories and implicit parent additions are refused.", + "vcs.actions.risk.delete": "This requests deletion of every listed working file, including both rename or copy endpoints. The provider controls disk deletion; no keep-local option is sent and Lithe keeps no backup. Review file contents before continuing.", + "debug.tcp.startupTimedOut": "The debug adapter connection timed out.", + "debug.tcp.writeTimedOut": "The debug adapter did not accept outgoing data in time.", + "debug.tcp.bufferFull": "The debug adapter outgoing buffer is full.", + "debug.tcp.invalidEndpoint": "Enter a valid debug adapter host and a port between 1 and 65535.", + "debug.tcp.declined": "The debug adapter connection was not approved.", + "debug.tcp.confirmTitle": "Connect to an external debug adapter?", + "debug.tcp.confirmBody": "The debug adapter at {arg1} can access your debugging data. Plain TCP is not encrypted or authenticated. Connect only over a trusted network or a tunnel you configured.", + "debug.tcp.menu": "Connect to Debug Adapter…", + "debug.tcp.title": "Connect to Debug Adapter", + "debug.tcp.name": "External Debug Adapter", + "debug.tcp.help": "Connect to an already running DAP server using the active file's installed debugger. This is not a JVM JDWP or gdbserver port. No proxy or tunnel is created.", + "debug.tcp.request": "Request", + "debug.tcp.arguments": "Adapter Arguments (JSON)", + "debug.tcp.argumentsHelp": "Enter the launch or attach arguments required by this adapter, including its source path mappings if needed. These are separate from the DAP server's TCP address.", + "debug.tcp.invalidArguments": "Enter a JSON object of adapter arguments no larger than 256 KiB.", + "debug.tcp.attachRequest": "Attach Request", + "debug.tcp.launchRequest": "Launch Request", + "debug.tcp.sessionActive": "Stop the current debug session before connecting to a debug adapter.", + "debug.tcp.notStarted": "The debug adapter connection could not be started.", + "native.hideCommitDetails": "Hide commit details", + "native.sideBySideView": "Side-by-side view", + "settings.mcp.error.expired": "The request expired before execution. Check operation status before retrying.", + "settings.mcp.error.request": "The tool request failed. See the MCP client for the diagnostic details.", + "settings.mcp.error.transport": "Request delivery failed. Access remains enabled; the operation will not be replayed.", + "settings.mcp.error.undelivered": "The client no longer accepts this result. Check operation status before retrying.", + "settings.mcp.status.active": "Last tool call succeeded", + "settings.mcp.status.failed": "Last request failed · access remains enabled", + "settings.mcp.status.waiting": "Authorized · waiting for a tool call", "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…", "run.incompleteProjectScanTitle": "Project scan is incomplete", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index a7a1df71b8..b8d5ed4787 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -4595,7 +4595,6 @@ "native.hideBuildOutputToolWindow": "隐藏构建输出工具窗口", "native.hideBuildToolWindow": "隐藏构建工具窗口", "native.hideCommitDecorations": "隐藏提交装饰", - "native.hideCommitDetails": "隐藏提交详情", "native.hideConfigurationList": "收起运行配置列表", "native.hideDebugToolWindow": "隐藏调试工具窗口", "native.hideEntry": "隐藏入口", @@ -6065,7 +6064,6 @@ "native.showingThePreviousJavaEntriesWhileTheJavaLanguageServicePreparesTheProject": "Java 语言服务正在准备项目,暂时显示上次的 Java 入口。", "native.shownAcrossTheEntireWorkbenchIncludingToolbarsProjectTreeEditorTabsTerminalsAndStatusBar": "显示在整个工作台内,包括工具栏、项目文件树、编辑区、标签栏、终端和状态栏。", "native.sideBySide": "并排", - "native.sideBySideView": "并排视图", "native.signInToCodex": "登录 Codex", "native.signInToGitHub": "登录 GitHub", "native.signInToViewAndManagePullRequests": "登录后即可查看和管理拉取请求。", @@ -7666,15 +7664,8 @@ "settings.mcp.description": "仅授权此项目。连接的 Agent 可以检查环境、运行配置和输出;输出可能包含项目敏感信息。关闭项目或退出 IDE 后连接失效。", "settings.mcp.disable": "关闭 MCP", "settings.mcp.enable": "开启 MCP", - "settings.mcp.error.expired": "请求已在执行前过期。请先检查操作状态,再决定是否重试。", - "settings.mcp.error.request": "工具请求失败。请在 MCP 客户端查看详细诊断。", - "settings.mcp.error.transport": "请求传递失败。授权仍有效,操作不会自动重放。", - "settings.mcp.error.undelivered": "客户端已不再接收此结果。请先检查操作状态,再决定是否重试。", "settings.mcp.openProject": "打开项目后,可在这里配置 Agent 的 MCP 访问权限。", "settings.mcp.plugins": "声明 IDE 权限的已启用插件也可使用此项目授权。", - "settings.mcp.status.active": "最近一次工具调用成功", - "settings.mcp.status.failed": "最近一次请求失败 · 授权仍有效", - "settings.mcp.status.waiting": "已授权 · 等待工具调用", "settings.mcp.title": "MCP 配置", "settings.navigation": "设置导航", "settings.noMatching": "未找到设置", @@ -9020,20 +9011,15 @@ "native.theAgentDidNotAcceptTheRequestIt": "Agent 未接受该请求,可能已经停止。", "native.savedBreakpointsCouldNotBeRead": "无法读取已保存的断点。", "native.savedDebuggerSteppingFiltersCouldNotBeRead": "无法读取已保存的调试器步进过滤器。", - "native.theLanguageServiceThatHostsTheDebugAdapterIsUnavailable": "承载调试适配器的语言服务不可用。", "native.theDebugAdapterNeedsWhichWasNotFoundInstall": "调试适配器 {arg1} 需要 {arg2},但未找到。请先安装,然后重试。", "native.theDebugAdapterMustLaunchAnExecutableFromItsPluginPackage": "调试适配器 {arg1} 必须启动其插件包内的可执行文件。", "native.thePluginDoesNotProvideTheDebugAdapter": "插件没有提供调试适配器 {arg1}。", - "native.theDebugAdapterNamesNoLanguageServerToHostIt": "调试适配器 {arg1} 没有指定承载它的语言服务器。", "native.theDebugAdapterUsesTheTransportWhichLitheCannotStartYet": "调试适配器 {arg1} 使用 {arg2} 传输方式,Lithe 暂时无法启动。目前只支持 stdio 和由语言服务器承载的适配器。", "native.theDebugAdapterMustLaunchAPackagedExecutableOrASystemCommand": "调试适配器 {arg1} 必须启动插件包内的可执行文件或系统命令。", - "native.couldNotConnectToTheDebugAdapter": "无法连接调试适配器:{arg1}", - "native.theDebugAdapterConnectionIsStopped": "调试适配器的连接已停止。", - "native.theLanguageServerReturnedAnInvalidTcpPortForItsDebugAdapter": "{arg1} 语言服务器为其调试适配器返回了无效的 TCP 端口。", - "native.theLanguageServerOfThisPluginIsNotAvailable": "此插件的 {arg1} 语言服务器不可用。", "native.couldNotStartTheJavaTestResultListener": "无法启动 Java 测试结果监听器:{arg1}", "native.theJavaTestResultListenerDidNotBecome": "Java 测试结果监听器未能及时就绪。", "native.nodeJsDebuggingRequiresTheOfficialVscodeJs": "Node.js 调试需要官方的 vscode-js-debug 独立 DAP 包。请将 LITHE_JS_DEBUG_PATH 设置为其 dapDebugServer.js 文件,或将其解压到 .lithe/toolchains/js-debug。", + "native.couldNotConnectToTheDebugAdapter": "无法连接调试适配器:{arg1}", "native.dittoExitedWithCodeLld": "ditto 以代码 {arg1} 退出:{arg2}", "native.githubProducedAnInvalidRequest": "GitHub 产生了无效的请求", "native.githubReturnedAnInvalidHttpResponse": "GitHub 返回了无效的 HTTP 响应", @@ -9381,25 +9367,454 @@ "native.size": "大小", "native.thisFolderCannotBeRead": "无法读取此文件夹。", "native.thisFolderIsEmpty": "此文件夹为空", - "debug.tcp.startupTimedOut": "调试适配器连接超时。", - "debug.tcp.writeTimedOut": "调试适配器未能及时接收待发送数据。", - "debug.tcp.bufferFull": "调试适配器的发送缓冲区已满。", - "debug.tcp.invalidEndpoint": "请输入有效的调试适配器主机和 1 到 65535 之间的端口。", - "debug.tcp.declined": "未批准连接调试适配器。", - "debug.tcp.confirmTitle": "连接到外部调试适配器?", - "debug.tcp.confirmBody": "位于 {arg1} 的调试适配器可以访问你的调试数据。普通 TCP 不提供加密或身份认证。请仅通过可信网络或你自行配置的隧道连接。", - "debug.tcp.menu": "连接调试适配器…", - "debug.tcp.title": "连接调试适配器", - "debug.tcp.name": "外部调试适配器", - "debug.tcp.help": "使用当前文件已安装的调试器连接已运行的 DAP 服务。这不是 JVM JDWP 或 gdbserver 端口。不会创建代理或隧道。", - "debug.tcp.request": "请求", - "debug.tcp.arguments": "适配器参数(JSON)", - "debug.tcp.argumentsHelp": "输入此适配器所需的启动或附加参数,必要时包含源码路径映射。这些参数独立于 DAP 服务的 TCP 地址。", - "debug.tcp.invalidArguments": "请输入不超过 256 KiB 的适配器参数 JSON 对象。", - "debug.tcp.attachRequest": "附加请求", - "debug.tcp.launchRequest": "启动请求", - "debug.tcp.sessionActive": "请先停止当前调试会话,再连接调试适配器。", - "debug.tcp.notStarted": "无法启动调试适配器连接。", + "settings.declarativePlugins.sources.title": "插件源", + "settings.declarativePlugins.sources.description": "声明式插件市场的来源。预置来源由宿主重建;官方源可停用、不可删除。", + "settings.declarativePlugins.sources.add": "添加源", + "settings.declarativePlugins.sources.addPlaceholder": "https://github.com/owner/repo 或索引 URL", + "settings.declarativePlugins.sources.empty": "没有已添加的第三方源。", + "settings.declarativePlugins.sources.official": "官方", + "settings.declarativePlugins.sources.thirdParty": "第三方", + "settings.declarativePlugins.sources.remove": "删除", + "settings.declarativePlugins.sources.toggle": "启用或停用源 {name}", + "settings.declarativePlugins.sources.errorAlreadyAdded": "该来源已添加。", + "settings.declarativePlugins.sources.errorOfficialCannotBeRemoved": "官方来源不能删除。", + "settings.declarativePlugins.sources.errorNotFound": "该来源已不存在。", + "settings.declarativePlugins.sources.errorInvalidInput": "请输入插件索引或 GitHub 仓库的 https 地址。", + "settings.declarativePlugins.sources.errorLoad": "来源列表加载失败。", + "settings.declarativePlugins.sources.errorGeneric": "来源操作失败,请重试。", + "settings.declarativePlugins.sources.corruptNotice": "已保存的来源列表无法读取。原文件已保留在存储目录旁,列表已按预置来源重建。", + "settings.declarativePlugins.sources.retry": "重试", + "buildSystems.run.title": "任务与测试", + "buildSystems.run.action": "操作", + "buildSystems.run.chooseAction": "选择操作…", + "buildSystems.run.task": "任务", + "buildSystems.run.tests": "测试", + "buildSystems.run.scope": "范围", + "buildSystems.run.scope.workspace": "工作区", + "buildSystems.run.scope.project": "项目", + "buildSystems.run.scope.target": "目标", + "buildSystems.run.project": "项目", + "buildSystems.run.chooseProject": "选择项目…", + "buildSystems.run.target": "目标", + "buildSystems.run.chooseTarget": "选择目标…", + "buildSystems.run.modelTask": "模型任务", + "buildSystems.run.chooseTask": "选择模型任务…", + "buildSystems.run.suite": "测试套件筛选", + "buildSystems.run.case": "测试用例筛选", + "buildSystems.run.arguments": "参数(每行一个原样参数)", + "buildSystems.run.prepare": "准备任务 / 测试…", + "buildSystems.run.cancel": "取消任务 / 测试", + "buildSystems.run.review": "确认执行任务 / 测试", + "buildSystems.run.approve": "同意并执行", + "buildSystems.run.programs": "以下为原生审核的完整程序声明,包含模板;授权后由原生解析实际可执行程序。", + "buildSystems.run.failed": "任务或测试操作失败:{message}", + "buildSystems.run.retryDefinitions": "重新加载任务声明", + "buildSystems.run.modelReloadRequired": "模型缺失或已过期。请先使用上方的加载 / 重新加载模型(静态模型使用重新加载),再重试此操作。", + "buildSystems.run.phase.preparing": "正在保存并同步,随后准备确认信息…", + "buildSystems.run.phase.review": "等待确认", + "buildSystems.run.phase.starting": "正在订阅输出,随后执行…", + "buildSystems.run.phase.running": "任务 / 测试操作进行中", + "buildSystems.run.phase.stopping": "正在取消,等待原生清理完成…", + "buildSystems.run.phase.cancelFailed": "取消失败,请重试停止", + "buildSystems.run.phase.failed": "任务 / 测试准备失败", + "buildSystems.run.phase.ended": "任务 / 测试操作已结束", + "buildSystems.model.title": "构建模型", + "buildSystems.model.load": "加载模型…", + "buildSystems.model.reload": "重新加载模型…", + "buildSystems.model.cancel": "取消加载模型", + "buildSystems.model.retryCancel": "重试取消", + "buildSystems.model.reviewTitle": "确认加载构建模型", + "buildSystems.model.approve": "同意并加载", + "buildSystems.model.decline": "拒绝", + "buildSystems.model.plugin": "插件", + "buildSystems.model.buildSystem": "构建系统", + "buildSystems.model.workspace": "工作区", + "buildSystems.model.instance": "实例", + "buildSystems.model.operation": "操作", + "buildSystems.model.generation": "代次", + "buildSystems.model.projectCode": "此操作可能执行项目中的代码。", + "buildSystems.model.noProjectCode": "准备好的计划未声明执行项目代码。", + "buildSystems.model.network": "此操作可能访问网络。", + "buildSystems.model.noNetwork": "准备好的计划未声明访问网络。", + "buildSystems.model.failed": "加载模型失败:{message}。请检查工具或项目配置后重试。", + "buildSystems.model.cancelFailed": "取消失败:{message}。请先重试取消,再开始新的加载。", + "buildSystems.model.stderr": "模型加载标准错误输出", + "buildSystems.model.phase.preparing": "正在准备模型确认信息…", + "buildSystems.model.phase.review": "等待确认", + "buildSystems.model.phase.loading": "正在加载模型…", + "buildSystems.model.phase.stopping": "正在取消,等待操作清理完成…", + "buildSystems.model.phase.ready": "模型已加载", + "buildSystems.model.phase.declined": "已拒绝加载模型", + "buildSystems.model.phase.stopped": "模型操作已结束,未应用结果", + "buildSystems.model.phase.failed": "加载模型失败", + "buildSystems.model.phase.cancelFailed": "需要处理取消失败", + "buildSystems.close": "关闭", + "buildSystems.refresh": "重新加载", + "buildSystems.manifestRejected": "{plugin}:{message}", + "buildSystems.shadowed": "{plugin} 的构建系统由 {by} 接管", + "buildSystems.detectFailed": "构建识别失败:{message}", + "buildSystems.loading": "正在识别构建系统…", + "buildSystems.emptyState": "此工作区未检测到 {name} 工程。", + "buildSystems.instanceSelector": "实例根", + "buildSystems.modelError": "模型错误:{message}", + "buildSystems.staleBanner": "工程模型已过期,重新加载以应用更改。", + "buildSystems.optionsTitle": "选项", + "buildSystems.projectsTitle": "工程", + "buildSystems.fromModel": "来自模型", + "buildSystems.reset": "重置", + "buildSystems.resetOption": "重置 {name}", + "buildSystems.railLabel": "{name} 构建窗口", + "settings.declarativePluginDetails.noSelection": "选择一个插件以查看详情。", + "settings.declarativePluginDetails.loading": "正在加载插件详情…", + "settings.declarativePluginDetails.retry": "重试", + "settings.declarativePluginDetails.tabs": "插件详情分区", + "settings.declarativePluginDetails.tab.overview": "概览", + "settings.declarativePluginDetails.tab.features": "功能", + "settings.declarativePluginDetails.tab.settings": "设置", + "settings.declarativePluginDetails.tab.permissions": "权限与程序", + "settings.declarativePluginDetails.tab.versions": "版本与来源", + "settings.declarativePluginDetails.notice.diskCommunity": "此插件从磁盘安装,发布者与仓库未经认证;Lithe 会校验你批准过的确切字节。", + "settings.declarativePluginDetails.notice.community": "这是社区插件。来源信息仅供参考,不能单独证明官方发布者身份。", + "settings.declarativePluginDetails.action.enable": "启用", + "settings.declarativePluginDetails.action.disable": "停用", + "settings.declarativePluginDetails.action.rollback": "回滚…", + "settings.declarativePluginDetails.action.uninstall": "卸载", + "settings.declarativePluginDetails.field.status": "状态", + "settings.declarativePluginDetails.field.provides": "提供", + "settings.declarativePluginDetails.field.source": "来源", + "settings.declarativePluginDetails.field.settings": "设置", + "settings.declarativePluginDetails.field.update": "更新", + "settings.declarativePluginDetails.field.pluginId": "插件 ID", + "settings.declarativePluginDetails.field.version": "版本", + "settings.declarativePluginDetails.field.verification": "验证", + "settings.declarativePluginDetails.field.repository": "仓库", + "settings.declarativePluginDetails.field.archiveDigest": "归档 SHA-256", + "settings.declarativePluginDetails.field.contentDigest": "内容 SHA-256", + "settings.declarativePluginDetails.status.quarantined": "已隔离", + "settings.declarativePluginDetails.status.enabled": "已启用", + "settings.declarativePluginDetails.status.disabled": "已停用", + "settings.declarativePluginDetails.empty.features": "此插件未声明任何功能。", + "settings.declarativePluginDetails.empty.settings": "此插件未声明任何设置。", + "settings.declarativePluginDetails.empty.permissions": "此插件不请求任何权限。", + "settings.declarativePluginDetails.empty.programs": "此插件不会启动任何程序。", + "settings.declarativePluginDetails.settingsDisabled": "启用这个已接受的插件后才能编辑其设置。", + "settings.declarativePluginDetails.availability.available": "Windows 当前可用", + "settings.declarativePluginDetails.availability.declaredOnly": "已声明,但 Windows 当前尚未托管", + "settings.declarativePluginDetails.permissions": "权限", + "settings.declarativePluginDetails.programs": "此插件可以启动的程序", + "settings.declarativePluginDetails.program": "完整程序声明 {index}", + "settings.declarativePluginDetails.permission.backend.launch": "启动插件后台服务", + "settings.declarativePluginDetails.permission.credentials.store": "通过 Lithe 存储凭据", + "settings.declarativePluginDetails.permission.languageServer.launch": "启动语言服务器", + "settings.declarativePluginDetails.permission.process.build": "运行构建工具", + "settings.declarativePluginDetails.permission.process.run": "运行项目程序", + "settings.declarativePluginDetails.permission.process.test": "运行项目测试", + "settings.declarativePluginDetails.permission.process.vcs": "运行版本控制工具", + "settings.declarativePluginDetails.permission.project.generate": "生成项目文件", + "settings.declarativePluginDetails.permission.ui.contribute": "添加命令与工具窗口", + "settings.declarativePluginDetails.permission.ui.web": "显示插件提供的网页内容", + "settings.declarativePluginDetails.currentVersion": "当前版本", + "settings.declarativePluginDetails.previousVersion": "上一版本", + "settings.declarativePluginDetails.update.unknown": "插件市场更新数据尚未接入。", + "settings.declarativePluginDetails.source.bundled": "随 Lithe 内置", + "settings.declarativePluginDetails.source.marketplace": "插件市场", + "settings.declarativePluginDetails.source.disk": "本地磁盘包", + "settings.declarativePluginDetails.source.unknown": "未知", + "settings.declarativePluginDetails.verification.publisherSignature": "已验证发布者签名", + "settings.declarativePluginDetails.verification.hostSeal": "已验证为 Lithe 安装的一部分", + "settings.declarativePluginDetails.verification.approvedDigest": "与安装时批准的字节一致", + "settings.declarativePluginDetails.verification.rejected": "验证失败", + "settings.declarativePluginDetails.verification.unknown": "尚未验证", + "settings.declarativePluginList.search": "搜索名称或 ID;输入 / 显示筛选", + "settings.declarativePluginList.filters": "筛选", + "settings.declarativePluginList.reset": "清除筛选", + "settings.declarativePluginList.refresh": "刷新插件", + "settings.declarativePluginList.loading": "正在刷新插件…", + "settings.declarativePluginList.capability": "贡献", + "settings.declarativePluginList.capability.all": "全部贡献", + "settings.declarativePluginList.capability.language": "语言贡献", + "settings.declarativePluginList.state": "状态", + "settings.declarativePluginList.state.all": "全部状态", + "settings.declarativePluginList.state.enabled": "已启用", + "settings.declarativePluginList.state.disabled": "已停用", + "settings.declarativePluginList.state.quarantined": "已隔离", + "settings.declarativePluginList.source": "包位置", + "settings.declarativePluginList.source.all": "全部位置", + "settings.declarativePluginList.unavailableFilters": "更新、发布者和构建功能筛选暂不可用。", + "settings.declarativePluginList.count": "显示 {count} / {total} 个插件", + "settings.declarativePluginList.selectionHidden": "当前筛选隐藏了选中的插件。", + "settings.declarativePluginList.showSelection": "显示选中插件", + "settings.declarativePluginList.noMatches": "没有符合筛选条件的插件。", + "settings.declarativePlugins.marketplace.title": "插件市场", + "settings.declarativePlugins.marketplace.description": "浏览并安装已启用源中的插件。", + "settings.declarativePlugins.marketplace.searchPlaceholder": "搜索插件", + "settings.declarativePlugins.marketplace.allSources": "全部源", + "settings.declarativePlugins.marketplace.refresh": "刷新", + "settings.declarativePlugins.marketplace.refreshing": "正在刷新…", + "settings.declarativePlugins.marketplace.loading": "正在加载插件市场…", + "settings.declarativePlugins.marketplace.loadFailed": "无法加载插件市场。", + "settings.declarativePlugins.marketplace.retry": "重试", + "settings.declarativePlugins.marketplace.installedWarning": "无法读取已安装插件列表,安装状态可能不完整。", + "settings.declarativePlugins.marketplace.notConnected": "当前版本尚未接通插件市场后端。", + "settings.declarativePlugins.marketplace.empty": "没有匹配的插件。", + "settings.declarativePlugins.marketplace.noSources": "没有启用的市场源。", + "settings.declarativePlugins.marketplace.rejectedCount": "已忽略 {count} 个无效条目。", + "settings.declarativePlugins.marketplace.cacheProblem": "无法读取缓存索引。", + "settings.declarativePlugins.marketplace.refreshFailed": "源刷新失败。", + "settings.declarativePlugins.marketplace.install": "安装", + "settings.declarativePlugins.marketplace.update": "更新", + "settings.declarativePlugins.marketplace.preparing": "正在准备安装包…", + "settings.declarativePlugins.marketplace.installing": "正在安装…", + "settings.declarativePlugins.marketplace.awaitingConfirmation": "等待确认。", + "settings.declarativePlugins.marketplace.errorFallback": "操作失败。", + "settings.declarativePlugins.marketplace.state.installed": "已安装 {version}", + "settings.declarativePlugins.marketplace.state.updateAvailable": "有可用更新:{installed} → {available}", + "settings.declarativePlugins.marketplace.state.fromOtherSource": "已从 {source} 安装,更新仅来自该源。", + "settings.declarativePlugins.marketplace.state.managedOutside": "磁盘或内置安装,不由市场管理。", + "settings.declarativePlugins.marketplace.state.unsupported.platform": "暂无 {platform} 平台的安装包。", + "settings.declarativePlugins.marketplace.state.unsupported.api": "需要更新版本的 Lithe。", + "settings.declarativePlugins.marketplace.state.unsupported.unknown": "不可安装:{message}", + "settings.declarativePlugins.marketplace.review.title": "安装 {name} {version}?", + "settings.declarativePlugins.marketplace.review.source": "来源:{source}", + "settings.declarativePlugins.marketplace.review.officialVerified": "已验证官方发布者。", + "settings.declarativePlugins.marketplace.review.unverifiedNotice": "该来源及其发布者未经 Lithe 官方验证。", + "settings.declarativePlugins.marketplace.review.notice": "安装前请确认权限与完整程序声明。", + "settings.declarativePlugins.marketplace.review.permissions": "权限", + "settings.declarativePlugins.marketplace.review.newPrograms": "{count} 个新程序需要你确认。", + "settings.declarativePlugins.marketplace.error.cancelled": "操作已取消。", + "settings.declarativePlugins.marketplace.error.sourceUnavailable": "市场源已被移除或停用。", + "settings.declarativePlugins.marketplace.error.indexChanged": "源索引已更新,请重新发起安装。", + "settings.declarativePlugins.marketplace.error.approvalRequired": "需要明确确认后才能安装。", + "settings.declarativePlugins.marketplace.error.digestMismatch": "审查后安装包内容发生了变化。", + "settings.declarativePlugins.marketplace.error.tooManyOperations": "待处理的市场操作过多,请稍后重试。", + "buildOutput.title": "构建输出", + "buildOutput.emptyState": "运行构建任务后此处显示输出。", + "buildOutput.logLabel": "构建输出日志", + "buildOutput.truncatedLines": "… 已截断前 {count} 行", + "buildOutput.status.succeeded": "成功", + "buildOutput.status.failed": "失败", + "buildOutput.status.cancelled": "已取消", + "buildOutput.status.failedToStart": "启动失败", + "buildOutput.stopping": "正在停止…", + "buildOutput.stopLabel": "停止构建任务", + "buildOutput.stopFailed": "停止运行失败:{message}", + "buildOutput.clearLabel": "清空构建输出", + "buildOutput.close": "关闭", + "buildOutput.exitCode": "退出码 {code}", + "buildOutput.testsPassed": "测试通过", + "buildOutput.testsFailed": "测试失败", + "buildOutput.testsPassedCount": "{count} 个通过", + "buildOutput.testsFailedCount": "{count} 个失败", + "buildOutput.testsSkippedCount": "{count} 个跳过", + "buildOutput.moreProblems": "… 还有 {count} 条", + "buildOutput.diagnosticsTruncated": "… 已截断 {count} 条诊断", + "buildOutput.subscriptionFailed": "订阅构建输出失败:{message}", + "buildOutput.testsTruncated": "已省略 {count} 项测试详情", + "buildSystems.dependencies.title": "依赖", + "buildSystems.dependencies.selection": "依赖所属工程", + "buildSystems.dependencies.instance": "实例根工程", + "buildSystems.dependencies.load": "加载依赖…", + "buildSystems.dependencies.review": "审查依赖加载", + "buildSystems.dependencies.approve": "同意并加载", + "buildSystems.dependencies.tree": "依赖树", + "buildSystems.dependencies.empty": "未返回依赖。", + "buildSystems.dependencies.failed": "依赖加载失败:{message}", + "buildSystems.dependencies.stale": "依赖上下文已变化,请重新加载。", + "buildSystems.dependencies.more": "显示更多依赖", + "buildSystems.dependencies.truncated": "Core 已达到依赖树上限,部分节点未显示。", + "buildSystems.dependencies.providerUnavailable": "暂不支持 Provider 依赖加载。", + "plugins.execution.reviewTitle": "运行插件命令?", + "plugins.execution.failed": "无法运行此插件选择({code})。请保存文件并刷新操作,或选择受支持的范围。", + "plugins.execution.review": "工作区:{workspace}\n选择:{selection}\n命令:{command}", + "buildSystems.provider.programs": "本次请求批准的 Provider 程序", + "buildSystems.provider.shared": "使用现有的 {language} 语言服务器", + "plugin.build.providerNotNegotiated": "服务器未协商构建 Provider 支持。", + "plugin.build.providerFailed": "构建 Provider 未能完成本次请求,请重新加载后重试。", + "generator.title": "插件项目生成器", + "generator.description": "使用已安装的插件创建项目。", + "generator.plugins": "插件", + "generator.back": "返回", + "generator.search": "搜索生成器", + "generator.empty": "此宿主当前没有可用的项目生成器。", + "generator.projects": "新建项目", + "generator.generators": "生成器", + "generator.git": "创建 Git 仓库", + "generator.advanced": "高级字段", + "generator.invalidField": "请检查此字段的格式、必填值或运行时版本。", + "generator.chooseRuntime": "选择运行时目录", + "generator.requirements": "尚未启用语言或构建支持:{names}。仍可创建项目。", + "generator.review": "检查创建计划", + "generator.network": "此生成器可能访问网络。", + "generator.steps": "生成步骤:{count}", + "generator.create": "创建项目", + "generator.edit": "修改表单", + "generator.cancel": "取消生成", + "generator.openCreated": "打开已创建的项目", + "generator.archiveUnavailable": "归档生成器暂不可用,需要先接通受限下载和有界解压。", + "generator.cleanupFailed": "无法确认清理完成,已保留临时目录和插件锁;重试前请检查运行中的进程。", + "generator.destinationExists": "目标已存在,请更换名称或位置。", + "generator.cancelled": "已取消项目生成。", + "generator.timeout": "项目生成超时。", + "generator.toolMissing": "缺少所需命令,请安装并确保 PATH 中可找到它。", + "generator.stale": "已检查的输入发生变化或已过期,请重新检查创建计划。", + "generator.runtimeInvalid": "所选目录中没有受支持的运行时。", + "generator.batchArgumentUnsupported": "此命令使用批处理入口,参数含有不支持的 shell 字符。请修改输入或使用原生可执行程序。", + "generator.failed": "无法创建项目,请检查生成器输入、工具和目录权限。", + "plugins.settings.actionUnavailable": "此操作尚不可用,插件 Provider 函数调用链尚未接入。", + "plugins.settings.effect.immediate": "已保存并发送给正在运行的语言服务器。", + "plugins.settings.effect.nextRun": "已保存,下次运行时生效。", + "plugins.settings.effect.nextStart": "已保存。当前没有语言服务器接收此配置,下次启动时使用。", + "plugins.settings.effect.reloadUnavailable": "已保存。项目重载尚不可用,此更改尚未应用到当前项目。", + "plugins.settings.effect.restart": "已保存,下次启动语言服务器时生效。", + "plugins.settings.failed": "插件设置不可用:{code}。请重新读取设置后再试。", + "plugins.settings.inherit": "使用继承值", + "plugins.settings.reload": "重新读取设置并丢弃草稿", + "plugins.settings.storage": "保存范围", + "plugins.settings.storageInfo": "这些插件偏好是 Lithe 传给插件启动和工作区配置的值。保存只会写入所选范围下显示的路径。", + "plugins.settings.projectOverride": "此工作区有覆盖值", + "plugins.settings.recoveryRequired": "设置文件旁仍有恢复备份,已停止保存。请检查原始 .backup 文件,恢复或移除该备份后再编辑。", + "vcs.roots.sources": "插件版本控制根", + "vcs.roots.passive": "此根目录目前仅支持读取状态。", + "vcs.roots.ambiguous": "{path} 的版本控制归属不明确", + "vcs.roots.incomplete": "版本控制根识别不完整", + "vcs.roots.ownerUnavailable": "无法查询文件的版本控制归属", + "vcs.roots.fileOwner": "当前文件:{vcs} · {path}", + "vcs.roots.noOwner": "当前文件没有明确的版本控制归属", + "vcs.roots.checkingOwner": "正在查询当前文件的版本控制归属…", + "generator.downloadFailed": "无法下载项目归档,请检查 HTTPS 服务器地址和网络连接。", + "generator.archiveInvalid": "项目归档无效,或包含不安全的路径或链接,请检查生成器服务器。", + "generator.outputLimit": "项目生成超过下载或输出限制,请选择更小的模板或检查生成器。", + "plugins.settings.reloadPlugin": "在所有工作区重载插件", + "plugins.settings.reloadPluginInfo": "停止所有窗口中该插件的当前操作,重新加载已接受的程序包和保存的设置。语言服务会在下次请求时重新启动,其他插件不受影响。", + "plugins.settings.reloadingPlugin": "正在重载插件…", + "plugins.settings.reloadLater": "稍后", + "plugins.settings.effect.reloadRequired": "已保存。请确认重载,以应用该插件的修改。", + "plugins.settings.reloadProjectInfo": "在保存设置的项目窗口中重载此插件的构建模型。命令和 Provider 模型会先请求执行审批,其他窗口继续运行。", + "plugins.settings.effect.projectReloadUnavailable": "已保存。当前不支持仅在本工作区重载;修改会在本工作区下次启动插件时生效。", + "vcs.status.refresh": "读取状态", + "vcs.status.stop": "停止", + "vcs.status.retryStop": "重试回收", + "vcs.status.loading": "正在读取更改…", + "vcs.status.stopping": "正在停止版本控制 Provider…", + "vcs.status.cleanupFailed": "未能确认 Provider 已回收。请重试回收后再读取状态。", + "vcs.status.unavailable": "版本控制状态不可用。请刷新根目录或检查插件。", + "vcs.status.toolUnavailable": "请安装此插件所需的版本控制客户端,然后重试。", + "vcs.status.timeout": "读取状态超时。请等待回收完成后重试。", + "vcs.status.empty": "此工作区根目录中没有更改", + "vcs.status.changes": "只读版本控制更改", + "vcs.status.truncated": "Provider 返回的更改列表不完整", + "vcs.status.kind.added": "新增", + "vcs.status.kind.conflicted": "冲突", + "vcs.status.kind.copied": "复制", + "vcs.status.kind.deleted": "删除", + "vcs.status.kind.ignored": "已忽略", + "vcs.status.kind.missing": "缺失", + "vcs.status.kind.modified": "修改", + "vcs.status.kind.renamed": "重命名", + "vcs.status.kind.replaced": "替换", + "vcs.status.kind.switched": "已切换", + "vcs.status.kind.typeChanged": "类型更改", + "vcs.status.kind.unversioned": "未版本化", + "vcs.status.visibleLimit": "显示 {total} 项更改中的前 {count} 项", + "generator.disabled": "已停用", + "generator.enableOwner": "创建项目前请先启用此生成器所属的插件。", + "generator.enablePlugin": "启用 {name}", + "generator.findPlugin": "在插件市场中查找 {name}", + "generator.advisoryRequirements": "这些语言或构建插件不影响项目创建,可按需启用或安装。", + "generator.pluginUnavailable": "插件不可用或已发生变化,请刷新生成器目录。", + "generator.pluginEnableFailed": "启用请求未完成,请检查刷新后的插件状态再重试。", + "generator.refresh": "刷新生成器", + "plugins.functions.newAttempt": "再次执行", + "plugins.functions.retryCleanup": "重试停止", + "plugins.functions.review": "确认插件进程及工作区访问", + "plugins.functions.confirmAction": "此操作需要确认。请核对进程及访问范围后继续。", + "plugins.functions.workspace": "工作区", + "plugins.functions.function": "插件函数", + "plugins.functions.projectCode": "可能执行项目代码", + "plugins.functions.network": "可能访问网络", + "plugins.functions.confirm": "确认并执行", + "plugins.functions.failed": "插件操作失败:{code}。", + "plugins.functions.yes": "是", + "plugins.functions.no": "否", + "plugins.functions.phase.preparing": "正在准备操作确认…", + "plugins.functions.phase.review": "等待确认", + "plugins.functions.phase.starting": "正在启动插件进程…", + "plugins.functions.phase.running": "插件操作正在执行…", + "plugins.functions.phase.stopping": "正在停止此操作…", + "plugins.functions.phase.cleanupFailed": "尚未确认停止,已保留原操作的所有权,请重试停止。", + "plugins.functions.phase.succeeded": "插件操作已完成", + "plugins.functions.phase.failed": "插件操作失败", + "plugins.functions.phase.cancelled": "插件操作已取消", + "vcs.mapping.source": "Lithe 工作区映射 · {path}", + "vcs.mapping.sourceInfo": "根目录选择保存在 Lithe 应用数据中;保存不会修改 SVN 或 Git 配置。", + "vcs.mapping.select": "{path} 的版本控制", + "vcs.mapping.automatic": "歧义时保留未选择", + "vcs.mapping.save": "保存映射", + "vcs.mapping.cancel": "放弃草稿", + "vcs.mapping.conflict": "映射来源或根目录已变化。草稿已保留,请放弃草稿后从刷新后的来源重新选择。", + "vcs.mapping.failed": "无法保存映射,草稿已保留。请检查 Lithe 来源是否可写,刷新根目录后重试。", + "vcs.watch.failed": "自动根目录监听不可用或已超出限制。请手动刷新根目录;状态仍需点击“读取状态”。", + "vcs.roots.unavailable": "无法读取版本控制根目录或 Lithe 映射。请在清理完成后重试,或检查映射来源。", + "vcs.roots.refresh": "刷新根目录", + "pluginNative.title": "Windows 原生插件面板", + "pluginNative.placementUnavailable": "此原生面板位置在 Windows 上不可用。", + "pluginNative.unavailable": "此面板的插件函数当前不可用。", + "pluginNative.reset": "重置原生面板状态", + "pluginNative.close": "关闭原生插件面板", + "pluginNative.loading": "正在加载原生插件面板", + "pluginNative.stateLimit": "原生面板状态过大。", + "pluginNative.failed": "原生插件面板请求失败。", + "pluginNative.cancelled": "原生插件面板请求已取消。", + "pluginNative.timeout": "原生插件面板请求超时。", + "pluginNative.invalidData": "插件返回了无效的原生面板数据。", + "pluginNative.info": "{name} 的信息", + "pluginNative.empty": "没有项目", + "pluginNative.collapse": "收起 {name}", + "pluginNative.expand": "展开 {name}", + "vcs.history.read": "读取历史", + "vcs.history.compareBase": "与基线比较", + "vcs.history.readFile": "读取文件历史", + "vcs.history.loading": "正在读取历史…", + "vcs.history.stopping": "正在停止版本控制 Provider…", + "vcs.history.cleanupFailed": "未能确认 Provider 已回收。请先重试回收,再读取历史。", + "vcs.history.failed": "版本控制历史不可用。请刷新根目录或检查插件。", + "vcs.history.entries": "只读版本控制历史", + "vcs.history.noMessage": "(无提交信息)", + "vcs.history.more": "读取更多", + "vcs.history.limit": "历史显示已达到安全上限", + "vcs.history.empty": "没有历史记录", + "vcs.history.compareTitle": "{file}:基线比较", + "vcs.actions.checkin": "提交选中变更", + "vcs.actions.checkinRemote": "将选中变更提交到服务器", + "vcs.actions.rollback": "回滚选中变更", + "vcs.actions.update": "更新工作副本", + "vcs.actions.message": "提交消息", + "vcs.actions.cancel": "取消", + "vcs.actions.confirm": "确认并执行", + "vcs.actions.count": "影响 {count} 个路径", + "vcs.actions.running": "VCS 操作执行中。停止后仍可能已产生变更。", + "vcs.actions.completed": "VCS 操作已完成。", + "vcs.actions.unknown": "操作可能已修改工作副本或服务器。请先刷新并核对结果,再决定是否重试。", + "vcs.actions.conflicts": " {count} 个路径存在冲突。", + "vcs.actions.risk.checkin": "仅提交列出的路径,包含移动前后两端。新增或缺失文件可能一并记录。没有独立发布步骤的提供方会直接提交到服务器。", + "vcs.actions.risk.rollback": "列出路径的本地变更将被丢弃。回滚新增文件可能将其删除。此处无法撤销该操作。", + "vcs.actions.risk.update": "这会更新显示的整个工作副本,包括无变更的文件,并可能访问服务器或产生冲突。", + "vcs.actions.error.vcs.authentication.failed": "身份验证失败,请检查 VCS 客户端凭据。", + "vcs.actions.error.vcs.conflict": "提供方报告冲突,请刷新后处理。", + "vcs.actions.error.vcs.outOfDate": "工作副本已过期,请刷新后更新。", + "vcs.actions.error.vcs.tool.unavailable": "VCS 客户端不可用,请检查安装。", + "vcs.actions.error.vcs.workingCopy.locked": "工作副本已锁定,请通过 VCS 客户端检查。", + "vcs.actions.error.vcs.workingCopy.unsupported": "不支持此工作副本格式。", + "vcs.actions.error.vcs.capability.unsupported": "此提供方未协商所请求的操作。", + "vcs.actions.error.vcs.provider.pathUnavailable": "无法安全修改所选范围。请在单一无歧义根中选择独立文件;更新要求打开整个工作副本。", + "vcs.actions.error.requestTimeout": "操作超时,请先检查结果再重试。", + "vcs.actions.error.requestCancelled": "操作已取消,请先检查结果再重试。", + "vcs.actions.wholeCopy": "整个工作副本(所有文件)", + "native.theLanguageServiceThatHostsTheDebugAdapterIsUnavailable": "承载调试适配器的语言服务不可用。", + "native.theDebugAdapterNamesNoLanguageServerToHostIt": "调试适配器 {arg1} 没有指定承载它的语言服务器。", + "native.theDebugAdapterConnectionIsStopped": "调试适配器的连接已停止。", + "native.theLanguageServerReturnedAnInvalidTcpPortForItsDebugAdapter": "{arg1} 语言服务器为其调试适配器返回了无效的 TCP 端口。", + "native.theLanguageServerOfThisPluginIsNotAvailable": "此插件的 {arg1} 语言服务器不可用。", "debugger.disassembly.title": "反汇编", "debugger.disassembly.steppingByInstruction": "按指令单步", "debugger.disassembly.loading": "正在加载指令…", @@ -9448,6 +9863,42 @@ "debugger.step.overInstruction": "按指令单步跳过", "debugger.step.intoInstruction": "按指令单步进入", "debugger.disassembly.stepsByLine": "该适配器不支持按指令单步,单步仍按源码行前进", + "vcs.actions.resolve": "标记为已解决", + "vcs.actions.cleanup": "清理工作副本", + "vcs.actions.risk.resolve": "将使用当前已保存内容把列出的冲突文件标记为已解决。继续前请逐一检查文件;此操作会更改工作副本的冲突元数据。", + "vcs.actions.risk.cleanup": "这会请求提供方清理显示的整个工作副本,包括状态列表中没有变更的路径。它可能修改工作副本元数据和锁;完成前请勿运行其他版本控制客户端。", + "vcs.actions.add": "将所选文件加入版本控制", + "vcs.actions.delete": "通过版本控制删除所选文件", + "vcs.actions.risk.add": "仅允许添加此根目录直属的现存普通文件。目录和隐式添加父目录的操作均会被拒绝。", + "vcs.actions.risk.delete": "此操作请求删除列出的每一个工作区文件,包括重命名或复制的两个端点。磁盘删除由 Provider 执行;请求不包含保留本地文件选项,Lithe 也不会备份。继续前请检查文件内容。", + "debug.tcp.startupTimedOut": "调试适配器连接超时。", + "debug.tcp.writeTimedOut": "调试适配器未能及时接收待发送数据。", + "debug.tcp.bufferFull": "调试适配器的发送缓冲区已满。", + "debug.tcp.invalidEndpoint": "请输入有效的调试适配器主机和 1 到 65535 之间的端口。", + "debug.tcp.declined": "未批准连接调试适配器。", + "debug.tcp.confirmTitle": "连接到外部调试适配器?", + "debug.tcp.confirmBody": "位于 {arg1} 的调试适配器可以访问你的调试数据。普通 TCP 不提供加密或身份认证。请仅通过可信网络或你自行配置的隧道连接。", + "debug.tcp.menu": "连接调试适配器…", + "debug.tcp.title": "连接调试适配器", + "debug.tcp.name": "外部调试适配器", + "debug.tcp.help": "使用当前文件已安装的调试器连接已运行的 DAP 服务。这不是 JVM JDWP 或 gdbserver 端口。不会创建代理或隧道。", + "debug.tcp.request": "请求", + "debug.tcp.arguments": "适配器参数(JSON)", + "debug.tcp.argumentsHelp": "输入此适配器所需的启动或附加参数,必要时包含源码路径映射。这些参数独立于 DAP 服务的 TCP 地址。", + "debug.tcp.invalidArguments": "请输入不超过 256 KiB 的适配器参数 JSON 对象。", + "debug.tcp.attachRequest": "附加请求", + "debug.tcp.launchRequest": "启动请求", + "debug.tcp.sessionActive": "请先停止当前调试会话,再连接调试适配器。", + "debug.tcp.notStarted": "无法启动调试适配器连接。", + "native.hideCommitDetails": "隐藏提交详情", + "native.sideBySideView": "并排视图", + "settings.mcp.error.expired": "请求已在执行前过期。请先检查操作状态,再决定是否重试。", + "settings.mcp.error.request": "工具请求失败。请在 MCP 客户端查看详细诊断。", + "settings.mcp.error.transport": "请求传递失败。授权仍有效,操作不会自动重放。", + "settings.mcp.error.undelivered": "客户端已不再接收此结果。请先检查操作状态,再决定是否重试。", + "settings.mcp.status.active": "最近一次工具调用成功", + "settings.mcp.status.failed": "最近一次请求失败 · 授权仍有效", + "settings.mcp.status.waiting": "已授权 · 等待工具调用", "plugins.java.installNotice": "Java Support 现已独立安装。请从插件市场安装,或使用“从磁盘安装”导入离线包。原有 Java 设置会保留。", "plugins.java.installTemplates": "安装 Java Support,获取 Java、Maven 和 Spring 项目模板…", "run.incompleteProjectScanTitle": "项目扫描不完整", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 0a3b3706d3..16f3d6fec6 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -39606,17 +39606,6 @@ "arguments": [] } }, - { - "id": "native.hideCommitDetails", - "description": "Native UI: Hide commit details", - "platforms": [ - "macos" - ], - "macos": { - "key": "Hide commit details", - "arguments": [] - } - }, { "id": "native.hideConfigurationList", "description": "Native UI: Hide configuration list", @@ -57107,17 +57096,6 @@ "arguments": [] } }, - { - "id": "native.sideBySideView", - "description": "Native UI: Side-by-side view", - "platforms": [ - "macos" - ], - "macos": { - "key": "Side-by-side view", - "arguments": [] - } - }, { "id": "native.signInToCodex", "description": "Native UI: Sign in to Codex", @@ -71630,34 +71608,6 @@ "windows" ] }, - { - "id": "settings.mcp.error.expired", - "description": "Windows IDE MCP request error: expired", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.mcp.error.request", - "description": "Windows IDE MCP request error: request", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.mcp.error.transport", - "description": "Windows IDE MCP request error: transport", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.mcp.error.undelivered", - "description": "Windows IDE MCP request error: undelivered", - "platforms": [ - "windows" - ] - }, { "id": "settings.mcp.openProject", "description": "Windows UI: settings.mcp.openProject", @@ -71672,27 +71622,6 @@ "windows" ] }, - { - "id": "settings.mcp.status.active", - "description": "Windows IDE MCP request status: active", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.mcp.status.failed", - "description": "Windows IDE MCP request status: failed", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.mcp.status.waiting", - "description": "Windows IDE MCP request status: waiting", - "platforms": [ - "windows" - ] - }, { "id": "settings.mcp.title", "description": "Windows UI: settings.mcp.title", @@ -83899,60 +83828,6 @@ "arguments": [] } }, - { - "id": "native.theLanguageServiceThatHostsTheDebugAdapterIsUnavailable", - "description": "Native UI: The language service that hosts the debug adapter is unavailable.", - "platforms": [ - "macos" - ], - "macos": { - "key": "The language service that hosts the debug adapter is unavailable.", - "arguments": [] - } - }, - { - "id": "native.theDebugAdapterConnectionIsStopped", - "description": "Native UI: The debug adapter connection is stopped.", - "platforms": [ - "macos" - ], - "macos": { - "key": "The debug adapter connection is stopped.", - "arguments": [] - } - }, - { - "id": "native.theLanguageServerReturnedAnInvalidTcpPortForItsDebugAdapter", - "description": "Native UI: The %@ language server returned an invalid TCP port for its debug adapter.", - "platforms": [ - "macos" - ], - "macos": { - "key": "The %@ language server returned an invalid TCP port for its debug adapter.", - "arguments": [ - "arg1" - ] - }, - "parameters": { - "arg1": "string" - } - }, - { - "id": "native.theLanguageServerOfThisPluginIsNotAvailable", - "description": "Native UI: The %@ language server of this plugin is not available.", - "platforms": [ - "macos" - ], - "macos": { - "key": "The %@ language server of this plugin is not available.", - "arguments": [ - "arg1" - ] - }, - "parameters": { - "arg1": "string" - } - }, { "id": "native.couldNotStartTheJavaTestResultListener", "description": "Native UI: Could not start the Java test result listener: %@", @@ -85456,17 +85331,6 @@ "arguments": [] } }, - { - "id": "native.theDebugAdapterRejectedTheRequest", - "description": "Native UI: The Debug Adapter rejected the request.", - "platforms": [ - "macos" - ], - "macos": { - "key": "The Debug Adapter rejected the request.", - "arguments": [] - } - }, { "id": "native.theDebugAdapterReturnedAnInvalidResponse", "description": "Native UI: The Debug Adapter returned an invalid %@ response.", @@ -88352,1142 +88216,4262 @@ } }, { - "id": "native.theDebugAdapterNeedsWhichWasNotFoundInstall", - "description": "Native UI: The debug adapter %@ needs %@, which was not found. Install it, then try again.", + "id": "buildOutput.clearLabel", + "description": "Windows build output clear control label", "platforms": [ - "macos" - ], - "macos": { - "key": "The debug adapter %@ needs %@, which was not found. Install it, then try again.", - "arguments": [ - "arg1", - "arg2" - ] - }, - "parameters": { - "arg1": "string", - "arg2": "string" - } + "windows" + ] }, { - "id": "native.theDebugAdapterMustLaunchAnExecutableFromItsPluginPackage", - "description": "Native UI: The debug adapter %@ must launch an executable from its plugin package.", + "id": "buildOutput.close", + "description": "Windows build output close control label", "platforms": [ - "macos" + "windows" + ] + }, + { + "id": "buildOutput.diagnosticsTruncated", + "description": "Windows build output notice that Core-decoded diagnostics were cut by the session cap", + "platforms": [ + "windows" ], - "macos": { - "key": "The debug adapter %@ must launch an executable from its plugin package.", - "arguments": [ - "arg1" - ] - }, "parameters": { - "arg1": "string" + "count": "integer" } }, { - "id": "native.openAJavaProjectBeforeConnectingTheDebugger", - "description": "Native UI: Open a Java project before connecting the debugger", + "id": "buildOutput.emptyState", + "description": "Windows build output panel empty state before the first run", "platforms": [ - "macos" - ], - "macos": { - "key": "Open a Java project before connecting the debugger", - "arguments": [] - } + "windows" + ] }, { - "id": "native.chooseARunConfigurationBeforeStartingDebug", - "description": "Native UI: Choose a Run configuration before starting Debug", + "id": "buildOutput.exitCode", + "description": "Windows build output exit-code detail of a finished run", "platforms": [ - "macos" + "windows" ], - "macos": { - "key": "Choose a Run configuration before starting Debug", - "arguments": [] + "parameters": { + "code": "integer" } }, { - "id": "native.openASourceFileOrChooseAProjectRunConfiguration", - "description": "Native UI: Open a source file or choose a project Run configuration", + "id": "buildOutput.logLabel", + "description": "Accessible label of the Windows build output log region", "platforms": [ - "macos" - ], - "macos": { - "key": "Open a source file or choose a project Run configuration", - "arguments": [] - } + "windows" + ] }, { - "id": "native.couldNotFindTheJavaSourceFor", - "description": "Native UI: Could not find the Java source for %@", + "id": "buildOutput.moreProblems", + "description": "Windows build output notice that further problem rows are collapsed", "platforms": [ - "macos" + "windows" ], - "macos": { - "key": "Could not find the Java source for %@", - "arguments": [ - "arg1" - ] - }, "parameters": { - "arg1": "string" + "count": "integer" } }, { - "id": "native.doesNotSupportDebugYet", - "description": "Native UI: %@ does not support Debug yet", + "id": "buildOutput.status.cancelled", + "description": "Windows build output run state: cancelled", "platforms": [ - "macos" - ], - "macos": { - "key": "%@ does not support Debug yet", - "arguments": [ - "arg1" - ] - }, - "parameters": { - "arg1": "string" - } + "windows" + ] }, { - "id": "native.debugSupportIsNotAvailableFor", - "description": "Native UI: Debug support is not available for %@", + "id": "buildOutput.status.failed", + "description": "Windows build output run state: failed", "platforms": [ - "macos" - ], - "macos": { - "key": "Debug support is not available for %@", - "arguments": [ - "arg1" - ] - }, - "parameters": { - "arg1": "string" - } + "windows" + ] }, { - "id": "native.theStoppedSourceFileIsNoLongerAvailable", - "description": "Native UI: The stopped source file is no longer available: %@", + "id": "buildOutput.status.failedToStart", + "description": "Windows build output run state: the command never started", "platforms": [ - "macos" - ], - "macos": { - "key": "The stopped source file is no longer available: %@", - "arguments": [ - "arg1" - ] - }, - "parameters": { - "arg1": "string" - } + "windows" + ] }, { - "id": "native.placeTheCaretInASourceFileToSetABreakpoint", - "description": "Native UI: Place the caret in a source file to set a breakpoint", + "id": "buildOutput.status.succeeded", + "description": "Windows build output run state: succeeded", "platforms": [ - "macos" - ], - "macos": { - "key": "Place the caret in a source file to set a breakpoint", - "arguments": [] - } + "windows" + ] }, { - "id": "native.debuggingIsNotSupportedForThisFileType", - "description": "Native UI: Debugging is not supported for this file type", + "id": "buildOutput.stopFailed", + "description": "Windows build output notice that cancelling the run through the port failed", "platforms": [ - "macos" + "windows" ], - "macos": { - "key": "Debugging is not supported for this file type", - "arguments": [] + "parameters": { + "message": "string" } }, { - "id": "native.runToCursorIsUnavailableForTheActiveDebugSession", - "description": "Native UI: Run to Cursor is unavailable for the active debug session", + "id": "buildOutput.stopLabel", + "description": "Windows build output stop control label", "platforms": [ - "macos" - ], - "macos": { - "key": "Run to Cursor is unavailable for the active debug session", - "arguments": [] - } + "windows" + ] }, { - "id": "native.noExecutableLocationWasFoundAtTheCursor", - "description": "Native UI: No executable location was found at the cursor", + "id": "buildOutput.stopping", + "description": "Windows build output run state: a stop was requested and is pending", "platforms": [ - "macos" - ], - "macos": { - "key": "No executable location was found at the cursor", - "arguments": [] - } + "windows" + ] }, { - "id": "native.noLanguageProviderIsAvailableForThisFile", - "description": "Native UI: No language provider is available for this file", + "id": "buildOutput.subscriptionFailed", + "description": "Windows build output subscription acquisition failure", "platforms": [ - "macos" + "windows" ], - "macos": { - "key": "No language provider is available for this file", - "arguments": [] + "parameters": { + "message": "string" } }, { - "id": "native.selectAStackFrameToInspectVariables", - "description": "Native UI: Select a stack frame to inspect variables", + "id": "buildOutput.testsFailed", + "description": "Windows build output test summary when decoded tests failed or errored", "platforms": [ - "macos" - ], - "macos": { - "key": "Select a stack frame to inspect variables", - "arguments": [] - } + "windows" + ] }, { - "id": "native.initializingAdapter", - "description": "Native UI: Initializing Adapter", + "id": "buildOutput.testsFailedCount", + "description": "Windows build output failed and errored test count", "platforms": [ - "macos" + "windows" ], - "macos": { - "key": "Initializing Adapter", - "arguments": [] + "parameters": { + "count": "integer" } }, { - "id": "native.adapterReady", - "description": "Native UI: Adapter Ready", + "id": "buildOutput.testsPassed", + "description": "Windows build output test summary when every decoded test passed", "platforms": [ - "macos" - ], - "macos": { - "key": "Adapter Ready", - "arguments": [] - } + "windows" + ] }, { - "id": "native.loadThreads", - "description": "Native UI: Load threads", + "id": "buildOutput.testsPassedCount", + "description": "Windows build output passed test count", "platforms": [ - "macos" + "windows" ], - "macos": { - "key": "Load threads", - "arguments": [] + "parameters": { + "count": "integer" } }, { - "id": "native.selectThread", - "description": "Native UI: Select thread", + "id": "buildOutput.testsSkippedCount", + "description": "Windows build output skipped test count", "platforms": [ - "macos" + "windows" ], - "macos": { - "key": "Select thread", - "arguments": [] + "parameters": { + "count": "integer" } }, { - "id": "native.smartStepInto2", - "description": "Native UI: Smart Step Into", + "id": "buildOutput.testsTruncated", + "description": "Windows build output test detail truncation notice; summary stays authoritative", "platforms": [ - "macos" + "windows" ], - "macos": { - "key": "Smart Step Into", - "arguments": [] + "parameters": { + "count": "integer" } }, { - "id": "native.stepBack", - "description": "Native UI: Step Back", + "id": "buildOutput.title", + "description": "Windows build output panel title: Build Output", "platforms": [ - "macos" - ], - "macos": { - "key": "Step Back", - "arguments": [] - } + "windows" + ] }, { - "id": "native.javaSteppingFilters2", - "description": "Native UI: Java Stepping Filters…", + "id": "buildOutput.truncatedLines", + "description": "Windows build output notice that older log lines were evicted by the output budget", "platforms": [ - "macos" + "windows" ], - "macos": { - "key": "Java Stepping Filters…", - "arguments": [] + "parameters": { + "count": "integer" } }, { - "id": "native.connectToRunningJVM2", - "description": "Native UI: Connect to Running JVM…", + "id": "buildSystems.run.title", + "description": "Windows declared task/test workflow: title", "platforms": [ - "macos" - ], - "macos": { - "key": "Connect to Running JVM…", - "arguments": [] - } + "windows" + ] }, { - "id": "native.clearConsole", - "description": "Native UI: Clear Console", + "id": "buildSystems.run.action", + "description": "Windows declared task/test workflow: action", "platforms": [ - "macos" - ], - "macos": { - "key": "Clear Console", - "arguments": [] - } + "windows" + ] }, { - "id": "native.copyMethodName", - "description": "Native UI: Copy Method Name", + "id": "buildSystems.run.chooseAction", + "description": "Windows declared task/test workflow: chooseAction", "platforms": [ - "macos" - ], - "macos": { - "key": "Copy Method Name", - "arguments": [] - } + "windows" + ] }, { - "id": "native.copySourceLocation", - "description": "Native UI: Copy Source Location", + "id": "buildSystems.run.task", + "description": "Windows declared task/test workflow: task", "platforms": [ - "macos" - ], - "macos": { - "key": "Copy Source Location", - "arguments": [] - } + "windows" + ] }, { - "id": "native.copyRelativeLocation", - "description": "Native UI: Copy Relative Location", + "id": "buildSystems.run.tests", + "description": "Windows declared task/test workflow: tests", "platforms": [ - "macos" - ], - "macos": { - "key": "Copy Relative Location", - "arguments": [] - } + "windows" + ] }, { - "id": "native.copyThreadName", - "description": "Native UI: Copy Thread Name", + "id": "buildSystems.run.scope", + "description": "Windows declared task/test workflow: scope", "platforms": [ - "macos" - ], - "macos": { - "key": "Copy Thread Name", - "arguments": [] - } + "windows" + ] }, { - "id": "native.resumeThread", - "description": "Native UI: Resume Thread", + "id": "buildSystems.run.scope.workspace", + "description": "Windows declared task/test workflow: scope.workspace", "platforms": [ - "macos" - ], - "macos": { - "key": "Resume Thread", - "arguments": [] - } + "windows" + ] }, { - "id": "native.pauseThread", - "description": "Native UI: Pause Thread", + "id": "buildSystems.run.scope.project", + "description": "Windows declared task/test workflow: scope.project", "platforms": [ - "macos" - ], - "macos": { - "key": "Pause Thread", - "arguments": [] - } + "windows" + ] }, { - "id": "native.setValue", - "description": "Native UI: Set Value…", + "id": "buildSystems.run.scope.target", + "description": "Windows declared task/test workflow: scope.target", "platforms": [ - "macos" - ], - "macos": { - "key": "Set Value…", - "arguments": [] - } + "windows" + ] }, { - "id": "native.breakOnFieldAccess", - "description": "Native UI: Break on Field Access…", + "id": "buildSystems.run.project", + "description": "Windows declared task/test workflow: project", "platforms": [ - "macos" - ], - "macos": { - "key": "Break on Field Access…", - "arguments": [] - } + "windows" + ] }, { - "id": "native.copyValue", - "description": "Native UI: Copy Value", + "id": "buildSystems.run.chooseProject", + "description": "Windows declared task/test workflow: chooseProject", "platforms": [ - "macos" - ], - "macos": { - "key": "Copy Value", - "arguments": [] - } + "windows" + ] }, { - "id": "native.copyExpression", - "description": "Native UI: Copy Expression", + "id": "buildSystems.run.target", + "description": "Windows declared task/test workflow: target", "platforms": [ - "macos" - ], - "macos": { - "key": "Copy Expression", - "arguments": [] - } + "windows" + ] }, { - "id": "native.addToWatches", - "description": "Native UI: Add to Watches", + "id": "buildSystems.run.chooseTarget", + "description": "Windows declared task/test workflow: chooseTarget", "platforms": [ - "macos" - ], - "macos": { - "key": "Add to Watches", - "arguments": [] - } + "windows" + ] }, { - "id": "native.evaluating", - "description": "Native UI: Evaluating…", + "id": "buildSystems.run.modelTask", + "description": "Windows declared task/test workflow: modelTask", "platforms": [ - "macos" - ], + "windows" + ] + }, + { + "id": "buildSystems.run.chooseTask", + "description": "Windows declared task/test workflow: chooseTask", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.suite", + "description": "Windows declared task/test workflow: suite", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.case", + "description": "Windows declared task/test workflow: case", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.arguments", + "description": "Windows declared task/test workflow: arguments", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.prepare", + "description": "Windows declared task/test workflow: prepare", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.cancel", + "description": "Windows declared task/test workflow: cancel", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.review", + "description": "Windows declared task/test workflow: review", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.approve", + "description": "Windows declared task/test workflow: approve", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.programs", + "description": "Windows declared task/test workflow: programs", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.failed", + "description": "Windows declared task/test workflow: failed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string" + } + }, + { + "id": "buildSystems.run.retryDefinitions", + "description": "Windows declared task/test workflow: retryDefinitions", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.modelReloadRequired", + "description": "Windows declared task/test workflow: modelReloadRequired", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.phase.preparing", + "description": "Windows declared task/test workflow: phase.preparing", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.phase.review", + "description": "Windows declared task/test workflow: phase.review", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.phase.starting", + "description": "Waiting for native output subscription before execution", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.phase.running", + "description": "Windows declared task/test workflow: phase.running", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.phase.stopping", + "description": "Windows declared task/test workflow: phase.stopping", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.phase.cancelFailed", + "description": "Windows declared task/test workflow: phase.cancelFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.phase.failed", + "description": "Windows declared task/test workflow: phase.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.phase.ended", + "description": "Windows declared task/test workflow: phase.ended", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.title", + "description": "Windows command model loading UI: title", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.load", + "description": "Windows command model loading UI: load", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.reload", + "description": "Windows command model loading UI: reload", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.cancel", + "description": "Windows command model loading UI: cancel", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.retryCancel", + "description": "Windows command model loading UI: retryCancel", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.reviewTitle", + "description": "Windows command model loading UI: reviewTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.approve", + "description": "Windows command model loading UI: approve", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.decline", + "description": "Windows command model loading UI: decline", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.plugin", + "description": "Windows command model loading UI: plugin", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.buildSystem", + "description": "Windows command model loading UI: buildSystem", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.workspace", + "description": "Windows command model loading UI: workspace", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.instance", + "description": "Windows command model loading UI: instance", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.operation", + "description": "Windows command model loading UI: operation", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.generation", + "description": "Windows command model loading UI: generation", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.projectCode", + "description": "Windows command model loading UI: projectCode", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.noProjectCode", + "description": "Windows command model loading UI: noProjectCode", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.network", + "description": "Windows command model loading UI: network", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.noNetwork", + "description": "Windows command model loading UI: noNetwork", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.failed", + "description": "Windows command model loading UI: failed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string" + } + }, + { + "id": "buildSystems.model.cancelFailed", + "description": "Windows command model loading UI: cancelFailed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string" + } + }, + { + "id": "buildSystems.model.stderr", + "description": "Windows command model loading UI: stderr", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.phase.preparing", + "description": "Windows command model loading UI: phase.preparing", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.phase.review", + "description": "Windows command model loading UI: phase.review", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.phase.loading", + "description": "Windows command model loading UI: phase.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.phase.stopping", + "description": "Windows command model loading UI: phase.stopping", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.phase.ready", + "description": "Windows command model loading UI: phase.ready", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.phase.declined", + "description": "Windows command model loading UI: phase.declined", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.phase.stopped", + "description": "Windows command model loading UI: phase.stopped", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.phase.failed", + "description": "Windows command model loading UI: phase.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.phase.cancelFailed", + "description": "Windows command model loading UI: phase.cancelFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.close", + "description": "Windows declarative build UI: buildSystems.close", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.detectFailed", + "description": "Windows declarative build UI: buildSystems.detectFailed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string" + } + }, + { + "id": "buildSystems.emptyState", + "description": "Windows declarative build UI: buildSystems.emptyState", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "buildSystems.fromModel", + "description": "Windows declarative build UI: buildSystems.fromModel", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.instanceSelector", + "description": "Windows declarative build UI: buildSystems.instanceSelector", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.loading", + "description": "Windows declarative build UI: buildSystems.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.manifestRejected", + "description": "Windows declarative build UI: buildSystems.manifestRejected", + "platforms": [ + "windows" + ], + "parameters": { + "plugin": "string", + "message": "string" + } + }, + { + "id": "buildSystems.modelError", + "description": "Windows declarative build UI: buildSystems.modelError", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string" + } + }, + { + "id": "buildSystems.optionsTitle", + "description": "Windows declarative build UI: buildSystems.optionsTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.projectsTitle", + "description": "Windows declarative build UI: buildSystems.projectsTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.railLabel", + "description": "Windows declarative build UI: buildSystems.railLabel", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "buildSystems.refresh", + "description": "Reload or retry Windows declarative build detection and models", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.reset", + "description": "Windows declarative build UI: buildSystems.reset", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.resetOption", + "description": "Windows declarative build UI: buildSystems.resetOption", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "buildSystems.shadowed", + "description": "Windows declarative build UI: buildSystems.shadowed", + "platforms": [ + "windows" + ], + "parameters": { + "plugin": "string", + "by": "string" + } + }, + { + "id": "buildSystems.staleBanner", + "description": "Windows declarative build UI: buildSystems.staleBanner", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.action.disable", + "description": "Windows plugin details action: disable", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.action.enable", + "description": "Windows plugin details action: enable", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.action.rollback", + "description": "Windows plugin details action: rollback", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.action.uninstall", + "description": "Windows plugin details action: uninstall", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.availability.available", + "description": "Windows plugin details: host capability available", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.availability.declaredOnly", + "description": "Windows plugin details: declaration not hosted", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.currentVersion", + "description": "Windows plugin details: current version", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.empty.features", + "description": "Windows plugin details: no features", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.empty.permissions", + "description": "Windows plugin details: no permissions", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.empty.programs", + "description": "Windows plugin details: no programs", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.empty.settings", + "description": "Windows plugin details: no settings", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.archiveDigest", + "description": "Windows plugin details field: archive SHA-256", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.contentDigest", + "description": "Windows plugin details field: content SHA-256", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.pluginId", + "description": "Windows plugin details field: plugin ID", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.provides", + "description": "Windows plugin details field: provides", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.repository", + "description": "Windows plugin details field: repository", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.settings", + "description": "Windows plugin details field: settings", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.source", + "description": "Windows plugin details field: source", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.status", + "description": "Windows plugin details field: status", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.update", + "description": "Windows plugin details field: update", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.verification", + "description": "Windows plugin details field: verification", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.version", + "description": "Windows plugin details field: version", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.loading", + "description": "Windows plugin details: loading", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.noSelection", + "description": "Windows plugin details: no selection", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.notice.community", + "description": "Windows plugin details: community trust notice", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.notice.diskCommunity", + "description": "Windows plugin details: disk community trust notice", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.backend.launch", + "description": "Windows plugin permission: launch backend", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.credentials.store", + "description": "Windows plugin permission: store credentials", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.languageServer.launch", + "description": "Windows plugin permission: launch language server", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.process.build", + "description": "Windows plugin permission: run builds", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.process.run", + "description": "Windows plugin permission: run programs", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.process.test", + "description": "Windows plugin permission: run tests", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.process.vcs", + "description": "Windows plugin permission: run VCS", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.project.generate", + "description": "Windows plugin permission: generate projects", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.ui.contribute", + "description": "Windows plugin permission: contribute UI", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.ui.web", + "description": "Windows plugin permission: show web content", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permissions", + "description": "Windows plugin details section: permissions", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.previousVersion", + "description": "Windows plugin details: previous version", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.program", + "description": "Windows plugin details: complete numbered program declaration", + "platforms": [ + "windows" + ], + "parameters": { + "index": "integer" + } + }, + { + "id": "settings.declarativePluginDetails.programs", + "description": "Windows plugin details section: programs", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.retry", + "description": "Windows plugin details: retry", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.settingsDisabled", + "description": "Windows plugin details: settings disabled", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.source.bundled", + "description": "Windows plugin details source: bundled", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.source.disk", + "description": "Windows plugin details source: disk", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.source.marketplace", + "description": "Windows plugin details source: marketplace", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.source.unknown", + "description": "Windows plugin details source: unknown", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.status.disabled", + "description": "Windows plugin details status: disabled", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.status.enabled", + "description": "Windows plugin details status: enabled", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.status.quarantined", + "description": "Windows plugin details status: quarantined", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.tab.features", + "description": "Windows plugin details tab: features", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.tab.overview", + "description": "Windows plugin details tab: overview", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.tab.permissions", + "description": "Windows plugin details tab: permissions and programs", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.tab.settings", + "description": "Windows plugin details tab: settings", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.tab.versions", + "description": "Windows plugin details tab: versions and source", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.tabs", + "description": "Windows plugin details: tab list label", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.update.unknown", + "description": "Windows plugin details: update status unavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.verification.approvedDigest", + "description": "Windows plugin details verification: approved digest", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.verification.hostSeal", + "description": "Windows plugin details verification: host seal", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.verification.publisherSignature", + "description": "Windows plugin details verification: publisher signature", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.verification.rejected", + "description": "Windows plugin details verification: rejected", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.verification.unknown", + "description": "Windows plugin details verification: unknown", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.capability", + "description": "Windows declarative plugin list: capability", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.capability.all", + "description": "Windows declarative plugin list: capability.all", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.capability.language", + "description": "Windows declarative plugin list: capability.language", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.count", + "description": "Windows declarative plugin list: count", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string", + "total": "string" + } + }, + { + "id": "settings.declarativePluginList.filters", + "description": "Windows declarative plugin list: filters", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.loading", + "description": "Windows declarative plugin list: loading", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.noMatches", + "description": "Windows declarative plugin list: noMatches", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.refresh", + "description": "Windows declarative plugin list: refresh", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.reset", + "description": "Windows declarative plugin list: reset", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.search", + "description": "Windows declarative plugin list: search", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.selectionHidden", + "description": "Windows declarative plugin list: selectionHidden", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.showSelection", + "description": "Windows declarative plugin list: showSelection", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.source", + "description": "Windows declarative plugin list: source", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.source.all", + "description": "Windows declarative plugin list: source.all", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.state", + "description": "Windows declarative plugin list: state", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.state.all", + "description": "Windows declarative plugin list: state.all", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.state.disabled", + "description": "Windows declarative plugin list: state.disabled", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.state.enabled", + "description": "Windows declarative plugin list: state.enabled", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.state.quarantined", + "description": "Windows declarative plugin list: state.quarantined", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginList.unavailableFilters", + "description": "Windows declarative plugin list: unavailableFilters", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.allSources", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.allSources", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.awaitingConfirmation", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.awaitingConfirmation", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.cacheProblem", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.cacheProblem", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.description", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.description", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.empty", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.empty", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.error.approvalRequired", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.error.approvalRequired", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.error.cancelled", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.error.cancelled", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.error.digestMismatch", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.error.digestMismatch", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.error.indexChanged", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.error.indexChanged", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.error.sourceUnavailable", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.error.sourceUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.error.tooManyOperations", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.error.tooManyOperations", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.errorFallback", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.errorFallback", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.install", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.install", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.installedWarning", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.installedWarning", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.installing", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.installing", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.loadFailed", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.loadFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.loading", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.noSources", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.noSources", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.notConnected", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.notConnected", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.preparing", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.preparing", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.refresh", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.refresh", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.refreshFailed", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.refreshFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.refreshing", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.refreshing", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.rejectedCount", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.rejectedCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "integer" + } + }, + { + "id": "settings.declarativePlugins.marketplace.retry", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.retry", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.review.newPrograms", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.review.newPrograms", + "platforms": [ + "windows" + ], + "parameters": { + "count": "integer" + } + }, + { + "id": "settings.declarativePlugins.marketplace.review.notice", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.review.notice", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.review.officialVerified", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.review.officialVerified", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.review.permissions", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.review.permissions", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.review.source", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.review.source", + "platforms": [ + "windows" + ], + "parameters": { + "source": "string" + } + }, + { + "id": "settings.declarativePlugins.marketplace.review.title", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.review.title", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string", + "version": "string" + } + }, + { + "id": "settings.declarativePlugins.marketplace.review.unverifiedNotice", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.review.unverifiedNotice", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.searchPlaceholder", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.searchPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.state.fromOtherSource", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.state.fromOtherSource", + "platforms": [ + "windows" + ], + "parameters": { + "source": "string" + } + }, + { + "id": "settings.declarativePlugins.marketplace.state.installed", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.state.installed", + "platforms": [ + "windows" + ], + "parameters": { + "version": "string" + } + }, + { + "id": "settings.declarativePlugins.marketplace.state.managedOutside", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.state.managedOutside", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.state.unsupported.api", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.state.unsupported.api", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.state.unsupported.platform", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.state.unsupported.platform", + "platforms": [ + "windows" + ], + "parameters": { + "platform": "string" + } + }, + { + "id": "settings.declarativePlugins.marketplace.state.unsupported.unknown", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.state.unsupported.unknown", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string" + } + }, + { + "id": "settings.declarativePlugins.marketplace.state.updateAvailable", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.state.updateAvailable", + "platforms": [ + "windows" + ], + "parameters": { + "available": "string", + "installed": "string" + } + }, + { + "id": "settings.declarativePlugins.marketplace.title", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.title", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.marketplace.update", + "description": "Windows declarative marketplace browse page: settings.declarativePlugins.marketplace.update", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.add", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.add", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.addPlaceholder", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.addPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.corruptNotice", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.corruptNotice", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.description", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.description", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.empty", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.empty", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.errorAlreadyAdded", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.errorAlreadyAdded", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.errorGeneric", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.errorGeneric", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.errorInvalidInput", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.errorInvalidInput", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.errorLoad", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.errorLoad", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.errorNotFound", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.errorNotFound", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.errorOfficialCannotBeRemoved", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.errorOfficialCannotBeRemoved", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.official", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.official", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.remove", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.remove", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.retry", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.retry", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.thirdParty", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.thirdParty", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.title", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.title", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.toggle", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.toggle", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "buildSystems.dependencies.title", + "description": "Windows plugin dependency loading: title", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.dependencies.selection", + "description": "Windows plugin dependency loading: selection", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.dependencies.instance", + "description": "Windows plugin dependency loading: instance", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.dependencies.load", + "description": "Windows plugin dependency loading: load", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.dependencies.review", + "description": "Windows plugin dependency loading: review", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.dependencies.approve", + "description": "Windows plugin dependency loading: approve", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.dependencies.tree", + "description": "Windows plugin dependency loading: tree", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.dependencies.empty", + "description": "Windows plugin dependency loading: empty", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.dependencies.failed", + "description": "Windows plugin dependency loading: failed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string" + } + }, + { + "id": "buildSystems.dependencies.stale", + "description": "Windows plugin dependency loading: stale", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.dependencies.more", + "description": "Windows plugin dependency loading: more", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.dependencies.truncated", + "description": "Windows plugin dependency loading: bounded Core result", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.dependencies.providerUnavailable", + "description": "Windows plugin dependency loading: providerUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.execution.failed", + "description": "Actionable failure of a plugin execution or discovery", + "parameters": { + "code": "string" + }, + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.execution.reviewTitle", + "description": "Confirm one exact declarative plugin execution", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.execution.review", + "description": "Reviewed workspace, selection and full command for a declarative execution", + "parameters": { + "workspace": "string", + "selection": "string", + "command": "string" + }, + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.provider.programs", + "description": "Windows build Provider approval and failures", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.provider.shared", + "description": "Windows build Provider approval and failures", + "platforms": [ + "windows" + ], + "parameters": { + "language": "string" + } + }, + { + "id": "plugin.build.providerNotNegotiated", + "description": "Windows build Provider approval and failures", + "platforms": [ + "windows" + ] + }, + { + "id": "plugin.build.providerFailed", + "description": "Windows build Provider approval and failures", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.title", + "description": "Windows plugin project generator: title", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.description", + "description": "Windows plugin project generator: description", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.plugins", + "description": "Windows plugin project generator: plugins", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.back", + "description": "Windows plugin project generator: back", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.search", + "description": "Windows plugin project generator: search", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.empty", + "description": "Windows plugin project generator: empty", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.projects", + "description": "Windows plugin project generator: projects", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.generators", + "description": "Windows plugin project generator: generators", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.git", + "description": "Windows plugin project generator: git", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.advanced", + "description": "Windows plugin project generator: advanced", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.invalidField", + "description": "Windows plugin project generator: invalidField", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.chooseRuntime", + "description": "Windows plugin project generator: chooseRuntime", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.requirements", + "description": "Windows plugin project generator: requirements", + "platforms": [ + "windows" + ], + "parameters": { + "names": "string" + } + }, + { + "id": "generator.review", + "description": "Windows plugin project generator: review", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.network", + "description": "Windows plugin project generator: network", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.steps", + "description": "Windows plugin project generator: steps", + "platforms": [ + "windows" + ], + "parameters": { + "count": "integer" + } + }, + { + "id": "generator.create", + "description": "Windows plugin project generator: create", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.edit", + "description": "Windows plugin project generator: edit", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.cancel", + "description": "Windows plugin project generator: cancel", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.openCreated", + "description": "Windows plugin project generator: openCreated", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.archiveUnavailable", + "description": "Windows plugin project generator: archiveUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.cleanupFailed", + "description": "Windows plugin project generator: cleanupFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.destinationExists", + "description": "Windows plugin project generator: destinationExists", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.cancelled", + "description": "Windows plugin project generator: cancelled", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.timeout", + "description": "Windows plugin project generator: timeout", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.toolMissing", + "description": "Windows plugin project generator: toolMissing", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.stale", + "description": "Windows plugin project generator: stale", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.runtimeInvalid", + "description": "Windows plugin project generator: runtimeInvalid", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.batchArgumentUnsupported", + "description": "Windows plugin project generator: batchArgumentUnsupported", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.failed", + "description": "Windows plugin project generator: failed", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.actionUnavailable", + "description": "Windows plugin settings: plugins.settings.actionUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.effect.immediate", + "description": "Windows plugin settings: plugins.settings.effect.immediate", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.effect.nextRun", + "description": "Windows plugin settings: plugins.settings.effect.nextRun", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.effect.nextStart", + "description": "Windows plugin settings: plugins.settings.effect.nextStart", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.effect.reloadUnavailable", + "description": "Windows plugin settings: plugins.settings.effect.reloadUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.effect.restart", + "description": "Windows plugin settings: plugins.settings.effect.restart", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.failed", + "description": "Windows plugin settings: plugins.settings.failed", + "platforms": [ + "windows" + ], + "parameters": { + "code": "string" + } + }, + { + "id": "plugins.settings.inherit", + "description": "Windows plugin settings: plugins.settings.inherit", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.reload", + "description": "Windows plugin settings: plugins.settings.reload", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.storage", + "description": "Windows plugin settings: plugins.settings.storage", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.storageInfo", + "description": "Windows plugin settings: plugins.settings.storageInfo", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.projectOverride", + "description": "Windows plugin settings: higher priority workspace override", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.recoveryRequired", + "description": "Windows plugin settings: failed native replacement requires recovery", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.roots.sources", + "description": "Windows passive plugin VCS root selector", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.roots.passive", + "description": "Windows passive plugin VCS root selector", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.roots.ambiguous", + "description": "Windows passive plugin VCS root selector", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "vcs.roots.incomplete", + "description": "Windows passive plugin VCS root selector", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.roots.ownerUnavailable", + "description": "Windows passive VCS current file ownership", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.roots.fileOwner", + "description": "Windows passive VCS current file ownership", + "platforms": [ + "windows" + ], + "parameters": { + "vcs": "string", + "path": "string" + } + }, + { + "id": "vcs.roots.noOwner", + "description": "Windows passive VCS current file ownership", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.roots.checkingOwner", + "description": "Windows passive VCS current file ownership", + "platforms": [ + "windows" + ] + }, + { + "id": "native.theDebugAdapterRejectedTheRequest", + "description": "Native UI: The Debug Adapter rejected the request.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Debug Adapter rejected the request.", + "arguments": [] + } + }, + { + "id": "native.theDebugAdapterNeedsWhichWasNotFoundInstall", + "description": "Native UI: The debug adapter %@ needs %@, which was not found. Install it, then try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The debug adapter %@ needs %@, which was not found. Install it, then try again.", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.theDebugAdapterMustLaunchAnExecutableFromItsPluginPackage", + "description": "Native UI: The debug adapter %@ must launch an executable from its plugin package.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The debug adapter %@ must launch an executable from its plugin package.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.openAJavaProjectBeforeConnectingTheDebugger", + "description": "Native UI: Open a Java project before connecting the debugger", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a Java project before connecting the debugger", + "arguments": [] + } + }, + { + "id": "native.chooseARunConfigurationBeforeStartingDebug", + "description": "Native UI: Choose a Run configuration before starting Debug", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a Run configuration before starting Debug", + "arguments": [] + } + }, + { + "id": "native.openASourceFileOrChooseAProjectRunConfiguration", + "description": "Native UI: Open a source file or choose a project Run configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a source file or choose a project Run configuration", + "arguments": [] + } + }, + { + "id": "native.couldNotFindTheJavaSourceFor", + "description": "Native UI: Could not find the Java source for %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not find the Java source for %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.doesNotSupportDebugYet", + "description": "Native UI: %@ does not support Debug yet", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ does not support Debug yet", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.debugSupportIsNotAvailableFor", + "description": "Native UI: Debug support is not available for %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Debug support is not available for %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.theStoppedSourceFileIsNoLongerAvailable", + "description": "Native UI: The stopped source file is no longer available: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "The stopped source file is no longer available: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.placeTheCaretInASourceFileToSetABreakpoint", + "description": "Native UI: Place the caret in a source file to set a breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Place the caret in a source file to set a breakpoint", + "arguments": [] + } + }, + { + "id": "native.debuggingIsNotSupportedForThisFileType", + "description": "Native UI: Debugging is not supported for this file type", + "platforms": [ + "macos" + ], + "macos": { + "key": "Debugging is not supported for this file type", + "arguments": [] + } + }, + { + "id": "native.runToCursorIsUnavailableForTheActiveDebugSession", + "description": "Native UI: Run to Cursor is unavailable for the active debug session", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run to Cursor is unavailable for the active debug session", + "arguments": [] + } + }, + { + "id": "native.noExecutableLocationWasFoundAtTheCursor", + "description": "Native UI: No executable location was found at the cursor", + "platforms": [ + "macos" + ], + "macos": { + "key": "No executable location was found at the cursor", + "arguments": [] + } + }, + { + "id": "native.noLanguageProviderIsAvailableForThisFile", + "description": "Native UI: No language provider is available for this file", + "platforms": [ + "macos" + ], + "macos": { + "key": "No language provider is available for this file", + "arguments": [] + } + }, + { + "id": "native.selectAStackFrameToInspectVariables", + "description": "Native UI: Select a stack frame to inspect variables", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select a stack frame to inspect variables", + "arguments": [] + } + }, + { + "id": "native.initializingAdapter", + "description": "Native UI: Initializing Adapter", + "platforms": [ + "macos" + ], + "macos": { + "key": "Initializing Adapter", + "arguments": [] + } + }, + { + "id": "native.adapterReady", + "description": "Native UI: Adapter Ready", + "platforms": [ + "macos" + ], + "macos": { + "key": "Adapter Ready", + "arguments": [] + } + }, + { + "id": "native.loadThreads", + "description": "Native UI: Load threads", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load threads", + "arguments": [] + } + }, + { + "id": "native.selectThread", + "description": "Native UI: Select thread", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select thread", + "arguments": [] + } + }, + { + "id": "native.smartStepInto2", + "description": "Native UI: Smart Step Into", + "platforms": [ + "macos" + ], + "macos": { + "key": "Smart Step Into", + "arguments": [] + } + }, + { + "id": "native.stepBack", + "description": "Native UI: Step Back", + "platforms": [ + "macos" + ], + "macos": { + "key": "Step Back", + "arguments": [] + } + }, + { + "id": "native.javaSteppingFilters2", + "description": "Native UI: Java Stepping Filters…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Java Stepping Filters…", + "arguments": [] + } + }, + { + "id": "native.connectToRunningJVM2", + "description": "Native UI: Connect to Running JVM…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connect to Running JVM…", + "arguments": [] + } + }, + { + "id": "native.clearConsole", + "description": "Native UI: Clear Console", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear Console", + "arguments": [] + } + }, + { + "id": "native.copyMethodName", + "description": "Native UI: Copy Method Name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Method Name", + "arguments": [] + } + }, + { + "id": "native.copySourceLocation", + "description": "Native UI: Copy Source Location", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Source Location", + "arguments": [] + } + }, + { + "id": "native.copyRelativeLocation", + "description": "Native UI: Copy Relative Location", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Relative Location", + "arguments": [] + } + }, + { + "id": "native.copyThreadName", + "description": "Native UI: Copy Thread Name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Thread Name", + "arguments": [] + } + }, + { + "id": "native.resumeThread", + "description": "Native UI: Resume Thread", + "platforms": [ + "macos" + ], + "macos": { + "key": "Resume Thread", + "arguments": [] + } + }, + { + "id": "native.pauseThread", + "description": "Native UI: Pause Thread", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pause Thread", + "arguments": [] + } + }, + { + "id": "native.setValue", + "description": "Native UI: Set Value…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Set Value…", + "arguments": [] + } + }, + { + "id": "native.breakOnFieldAccess", + "description": "Native UI: Break on Field Access…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Break on Field Access…", + "arguments": [] + } + }, + { + "id": "native.copyValue", + "description": "Native UI: Copy Value", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Value", + "arguments": [] + } + }, + { + "id": "native.copyExpression", + "description": "Native UI: Copy Expression", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Expression", + "arguments": [] + } + }, + { + "id": "native.addToWatches", + "description": "Native UI: Add to Watches", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add to Watches", + "arguments": [] + } + }, + { + "id": "native.evaluating", + "description": "Native UI: Evaluating…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Evaluating…", + "arguments": [] + } + }, + { + "id": "native.notAvailable", + "description": "Native UI: Not available", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not available", + "arguments": [] + } + }, + { + "id": "native.nestedException", + "description": "Native UI: Nested exception", + "platforms": [ + "macos" + ], + "macos": { + "key": "Nested exception", + "arguments": [] + } + }, + { + "id": "native.alwaysBreak", + "description": "Native UI: Always break", + "platforms": [ + "macos" + ], + "macos": { + "key": "Always break", + "arguments": [] + } + }, + { + "id": "native.unhandled", + "description": "Native UI: Unhandled", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unhandled", + "arguments": [] + } + }, + { + "id": "native.userUnhandled", + "description": "Native UI: User-unhandled", + "platforms": [ + "macos" + ], + "macos": { + "key": "User-unhandled", + "arguments": [] + } + }, + { + "id": "native.neverBreak", + "description": "Native UI: Never break", + "platforms": [ + "macos" + ], + "macos": { + "key": "Never break", + "arguments": [] + } + }, + { + "id": "native.loadingDebuggerVariables", + "description": "Native UI: Loading debugger variables", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading debugger variables", + "arguments": [] + } + }, + { + "id": "native.loadMoreDebuggerVariables", + "description": "Native UI: Load more debugger variables", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load more debugger variables", + "arguments": [] + } + }, + { + "id": "native.addMethodBreakpoint2", + "description": "Native UI: Add Method Breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add Method Breakpoint", + "arguments": [] + } + }, + { + "id": "native.editMethodBreakpoint2", + "description": "Native UI: Edit Method Breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit Method Breakpoint", + "arguments": [] + } + }, + { + "id": "native.classOrMethod", + "description": "Native UI: Class or method", + "platforms": [ + "macos" + ], + "macos": { + "key": "Class or method", + "arguments": [] + } + }, + { + "id": "native.condition", + "description": "Native UI: Condition", + "platforms": [ + "macos" + ], + "macos": { + "key": "Condition", + "arguments": [] + } + }, + { + "id": "native.hitCount", + "description": "Native UI: Hit count", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hit count", + "arguments": [] + } + }, + { + "id": "native.exceptionCondition", + "description": "Native UI: Exception condition", + "platforms": [ + "macos" + ], + "macos": { + "key": "Exception condition", + "arguments": [] + } + }, + { + "id": "native.logMessage", + "description": "Native UI: Log message", + "platforms": [ + "macos" + ], + "macos": { + "key": "Log message", + "arguments": [] + } + }, + { + "id": "native.waitingForDebugAdapterOutput", + "description": "Native UI: Waiting for Debug Adapter output…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Waiting for Debug Adapter output…", + "arguments": [] + } + }, + { + "id": "native.addWatch2", + "description": "Native UI: Add Watch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add Watch", + "arguments": [] + } + }, + { + "id": "native.editWatch", + "description": "Native UI: Edit Watch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit Watch", + "arguments": [] + } + }, + { + "id": "native.expression", + "description": "Native UI: Expression", + "platforms": [ + "macos" + ], + "macos": { + "key": "Expression", + "arguments": [] + } + }, + { + "id": "native.loadArg1More", + "description": "Native UI: Load %lld more", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load %lld more", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.dataAccessRead", + "description": "Native UI: data breakpoint access type Read", + "platforms": [ + "macos" + ], + "macos": { + "key": "Read", + "arguments": [] + } + }, + { + "id": "native.dataAccessWrite", + "description": "Native UI: data breakpoint access type Write", + "platforms": [ + "macos" + ], + "macos": { + "key": "Write", + "arguments": [] + } + }, + { + "id": "native.dataAccessReadWrite", + "description": "Native UI: data breakpoint access type Read/Write", + "platforms": [ + "macos" + ], + "macos": { + "key": "Read/Write", + "arguments": [] + } + }, + { + "id": "native.pauseTheProgramToRunDebuggerCommands", + "description": "Native UI: Pause the program to run debugger commands", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pause the program to run debugger commands", + "arguments": [] + } + }, + { + "id": "native.startADebugSessionToRunDebuggerCommands", + "description": "Native UI: Start a debug session to run debugger commands", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start a debug session to run debugger commands", + "arguments": [] + } + }, + { + "id": "native.enterADebuggerCommandTabToComplete", + "description": "Native UI: Enter a debugger command (Tab to complete)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a debugger command (Tab to complete)", + "arguments": [] + } + }, + { + "id": "native.debuggerCommandInput", + "description": "Native UI: Debugger command input", + "platforms": [ + "macos" + ], "macos": { - "key": "Evaluating…", + "key": "Debugger command input", + "arguments": [] + } + }, + { + "id": "native.debuggerCommandSuggestions", + "description": "Native UI: Debugger command suggestions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Debugger command suggestions", + "arguments": [] + } + }, + { + "id": "native.debugConsoleSuggestionKeys", + "description": "Native UI: ↑↓ Select · Tab Fill · Enter Run · Esc Close", + "platforms": [ + "macos" + ], + "macos": { + "key": "↑↓ Select · Tab Fill · Enter Run · Esc Close", + "arguments": [] + } + }, + { + "id": "native.noProgramToDebugWasFoundBuildItFirstOrChooseOneInTheSettingOfLookedIn", + "description": "Native UI: No program to debug was found. Build it first, or choose one in the “%@” setting of %@. Looked in: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "No program to debug was found. Build it first, or choose one in the “%@” setting of %@. Looked in: %@", + "arguments": [ + "arg1", + "arg2", + "arg3" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string", + "arg3": "string" + } + }, + { + "id": "native.noProgramToDebugWasFoundBuildItFirstOrChooseOneInTheSettingOf", + "description": "Native UI: No program to debug was found. Build it first, or choose one in the “%@” setting of %@.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No program to debug was found. Build it first, or choose one in the “%@” setting of %@.", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.theProgramChosenInTheSettingOfIsMissingOrNotExecutable", + "description": "Native UI: The program chosen in the “%@” setting of %@ is missing or not executable: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "The program chosen in the “%@” setting of %@ is missing or not executable: %@", + "arguments": [ + "arg1", + "arg2", + "arg3" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string", + "arg3": "string" + } + }, + { + "id": "native.foundAutomatically", + "description": "Native UI: Found automatically", + "platforms": [ + "macos" + ], + "macos": { + "key": "Found automatically", + "arguments": [] + } + }, + { + "id": "native.lookedIn", + "description": "Native UI: Looked in: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Looked in: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.theChosenFileIsMissingOrNotExecutable", + "description": "Native UI: The chosen file is missing or not executable.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The chosen file is missing or not executable.", "arguments": [] } }, { - "id": "native.notAvailable", - "description": "Native UI: Not available", + "id": "native.noPlaceToLookYetOpenTheFileYouWantToDebugOrChooseTheProgramYourself", + "description": "Native UI: No place to look yet. Open the file you want to debug, or choose the program yourself.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No place to look yet. Open the file you want to debug, or choose the program yourself.", + "arguments": [] + } + }, + { + "id": "native.thePluginDoesNotProvideTheDebugAdapter", + "description": "Native UI: The plugin does not provide the debug adapter %@.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The plugin does not provide the debug adapter %@.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.theDebugAdapterUsesTheTransportWhichLitheCannotStartYet", + "description": "Native UI: The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported.", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.theDebugAdapterMustLaunchAPackagedExecutableOrASystemCommand", + "description": "Native UI: The debug adapter %@ must launch a packaged executable or a system command.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The debug adapter %@ must launch a packaged executable or a system command.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "generator.downloadFailed", + "description": "Windows plugin project generator: controlled archive fetch failure", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.archiveInvalid", + "description": "Windows plugin project generator: unsafe or malformed archive", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.outputLimit", + "description": "Windows plugin project generator: download or output budget exceeded", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.reloadPlugin", + "description": "Windows plugin settings reload confirmation and state", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.reloadPluginInfo", + "description": "Windows plugin settings reload confirmation and state", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.reloadingPlugin", + "description": "Windows plugin settings reload confirmation and state", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.reloadLater", + "description": "Windows plugin settings reload confirmation and state", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.effect.reloadRequired", + "description": "Windows plugin settings reload confirmation and state", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.reloadProjectInfo", + "description": "Windows UI: project-scoped plugin settings reload help", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.settings.effect.projectReloadUnavailable", + "description": "Windows plugin settings reload confirmation and state", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.refresh", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.stop", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.retryStop", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.loading", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.stopping", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.cleanupFailed", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.unavailable", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.toolUnavailable", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.timeout", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.empty", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.changes", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.truncated", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.kind.added", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.kind.conflicted", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.kind.copied", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.kind.deleted", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.kind.ignored", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.kind.missing", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.kind.modified", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.kind.renamed", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.kind.replaced", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.kind.switched", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.kind.typeChanged", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.kind.unversioned", + "description": "Windows read-only plugin VCS status", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.status.visibleLimit", + "description": "Windows bounded read-only VCS status list", + "platforms": [ + "windows" + ], + "parameters": { + "count": "integer", + "total": "integer" + } + }, + { + "id": "generator.disabled", + "description": "Windows project generator plugin interaction: disabled", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.enableOwner", + "description": "Windows project generator plugin interaction: enableOwner", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.enablePlugin", + "description": "Windows project generator plugin interaction: enablePlugin", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "generator.findPlugin", + "description": "Windows project generator plugin interaction: findPlugin", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "generator.advisoryRequirements", + "description": "Windows project generator plugin interaction: advisoryRequirements", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.pluginUnavailable", + "description": "Windows project generator plugin interaction: pluginUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.pluginEnableFailed", + "description": "Windows project generator plugin interaction: pluginEnableFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "generator.refresh", + "description": "Windows project generator plugin interaction: refresh", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.newAttempt", + "description": "Windows settings plugin function: plugins.functions.newAttempt", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.retryCleanup", + "description": "Windows settings plugin function: plugins.functions.retryCleanup", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.review", + "description": "Windows settings plugin function: plugins.functions.review", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.confirmAction", + "description": "Windows settings plugin function: plugins.functions.confirmAction", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.workspace", + "description": "Windows settings plugin function: plugins.functions.workspace", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.function", + "description": "Windows settings plugin function: plugins.functions.function", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.projectCode", + "description": "Windows settings plugin function: plugins.functions.projectCode", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.network", + "description": "Windows settings plugin function: plugins.functions.network", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.confirm", + "description": "Windows settings plugin function: plugins.functions.confirm", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.failed", + "description": "Windows settings plugin function: plugins.functions.failed", + "platforms": [ + "windows" + ], + "parameters": { + "code": "string" + } + }, + { + "id": "plugins.functions.yes", + "description": "Windows settings plugin function: plugins.functions.yes", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.no", + "description": "Windows settings plugin function: plugins.functions.no", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.phase.preparing", + "description": "Windows settings plugin function: plugins.functions.phase.preparing", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.phase.review", + "description": "Windows settings plugin function: plugins.functions.phase.review", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.phase.starting", + "description": "Windows settings plugin function: plugins.functions.phase.starting", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.phase.running", + "description": "Windows settings plugin function: plugins.functions.phase.running", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.phase.stopping", + "description": "Windows settings plugin function: plugins.functions.phase.stopping", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.phase.cleanupFailed", + "description": "Windows settings plugin function: plugins.functions.phase.cleanupFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.phase.succeeded", + "description": "Windows settings plugin function: plugins.functions.phase.succeeded", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.phase.failed", + "description": "Windows settings plugin function: plugins.functions.phase.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.functions.phase.cancelled", + "description": "Windows settings plugin function: plugins.functions.phase.cancelled", "platforms": [ - "macos" - ], - "macos": { - "key": "Not available", - "arguments": [] - } + "windows" + ] }, { - "id": "native.nestedException", - "description": "Native UI: Nested exception", + "id": "vcs.mapping.source", + "description": "Windows Lithe-owned VCS root mappings and passive monitoring", "platforms": [ - "macos" + "windows" ], - "macos": { - "key": "Nested exception", - "arguments": [] + "parameters": { + "path": "string" } }, { - "id": "native.alwaysBreak", - "description": "Native UI: Always break", + "id": "vcs.mapping.sourceInfo", + "description": "Windows Lithe-owned VCS root mappings and passive monitoring", "platforms": [ - "macos" - ], - "macos": { - "key": "Always break", - "arguments": [] - } + "windows" + ] }, { - "id": "native.unhandled", - "description": "Native UI: Unhandled", + "id": "vcs.mapping.select", + "description": "Windows Lithe-owned VCS root mappings and passive monitoring", "platforms": [ - "macos" + "windows" ], - "macos": { - "key": "Unhandled", - "arguments": [] + "parameters": { + "path": "string" } }, { - "id": "native.userUnhandled", - "description": "Native UI: User-unhandled", + "id": "vcs.mapping.automatic", + "description": "Windows Lithe-owned VCS root mappings and passive monitoring", "platforms": [ - "macos" - ], - "macos": { - "key": "User-unhandled", - "arguments": [] - } + "windows" + ] }, { - "id": "native.neverBreak", - "description": "Native UI: Never break", + "id": "vcs.mapping.save", + "description": "Windows Lithe-owned VCS root mappings and passive monitoring", "platforms": [ - "macos" - ], - "macos": { - "key": "Never break", - "arguments": [] - } + "windows" + ] }, { - "id": "native.loadingDebuggerVariables", - "description": "Native UI: Loading debugger variables", + "id": "vcs.mapping.cancel", + "description": "Windows Lithe-owned VCS root mappings and passive monitoring", "platforms": [ - "macos" - ], - "macos": { - "key": "Loading debugger variables", - "arguments": [] - } + "windows" + ] }, { - "id": "native.loadMoreDebuggerVariables", - "description": "Native UI: Load more debugger variables", + "id": "vcs.mapping.conflict", + "description": "Windows Lithe-owned VCS root mappings and passive monitoring", "platforms": [ - "macos" - ], - "macos": { - "key": "Load more debugger variables", - "arguments": [] - } + "windows" + ] }, { - "id": "native.addMethodBreakpoint2", - "description": "Native UI: Add Method Breakpoint", + "id": "vcs.mapping.failed", + "description": "Windows Lithe-owned VCS root mappings and passive monitoring", "platforms": [ - "macos" - ], - "macos": { - "key": "Add Method Breakpoint", - "arguments": [] - } + "windows" + ] }, { - "id": "native.editMethodBreakpoint2", - "description": "Native UI: Edit Method Breakpoint", + "id": "vcs.watch.failed", + "description": "Windows Lithe-owned VCS root mappings and passive monitoring", "platforms": [ - "macos" - ], - "macos": { - "key": "Edit Method Breakpoint", - "arguments": [] - } + "windows" + ] }, { - "id": "native.classOrMethod", - "description": "Native UI: Class or method", + "id": "vcs.roots.unavailable", + "description": "Windows passive VCS root read and cleanup retry", "platforms": [ - "macos" - ], - "macos": { - "key": "Class or method", - "arguments": [] - } + "windows" + ] }, { - "id": "native.condition", - "description": "Native UI: Condition", + "id": "vcs.roots.refresh", + "description": "Windows passive VCS root read and cleanup retry", "platforms": [ - "macos" - ], - "macos": { - "key": "Condition", - "arguments": [] - } + "windows" + ] }, { - "id": "native.hitCount", - "description": "Native UI: Hit count", + "id": "pluginNative.title", + "description": "Windows native plugin panel", "platforms": [ - "macos" - ], - "macos": { - "key": "Hit count", - "arguments": [] - } + "windows" + ] }, { - "id": "native.exceptionCondition", - "description": "Native UI: Exception condition", + "id": "pluginNative.placementUnavailable", + "description": "Windows native plugin UI", "platforms": [ - "macos" - ], - "macos": { - "key": "Exception condition", - "arguments": [] - } + "windows" + ] }, { - "id": "native.logMessage", - "description": "Native UI: Log message", + "id": "pluginNative.unavailable", + "description": "Windows native plugin UI", "platforms": [ - "macos" - ], - "macos": { - "key": "Log message", - "arguments": [] - } + "windows" + ] }, { - "id": "native.waitingForDebugAdapterOutput", - "description": "Native UI: Waiting for Debug Adapter output…", + "id": "pluginNative.reset", + "description": "Windows native plugin UI", "platforms": [ - "macos" - ], - "macos": { - "key": "Waiting for Debug Adapter output…", - "arguments": [] - } + "windows" + ] }, { - "id": "native.addWatch2", - "description": "Native UI: Add Watch", + "id": "pluginNative.close", + "description": "Windows native plugin UI", "platforms": [ - "macos" - ], - "macos": { - "key": "Add Watch", - "arguments": [] - } + "windows" + ] }, { - "id": "native.editWatch", - "description": "Native UI: Edit Watch", + "id": "pluginNative.loading", + "description": "Windows native plugin UI", "platforms": [ - "macos" - ], - "macos": { - "key": "Edit Watch", - "arguments": [] - } + "windows" + ] }, { - "id": "native.expression", - "description": "Native UI: Expression", + "id": "pluginNative.stateLimit", + "description": "Windows native plugin UI", "platforms": [ - "macos" - ], - "macos": { - "key": "Expression", - "arguments": [] - } + "windows" + ] }, { - "id": "native.loadArg1More", - "description": "Native UI: Load %lld more", + "id": "pluginNative.failed", + "description": "Windows native plugin UI", "platforms": [ - "macos" + "windows" + ] + }, + { + "id": "pluginNative.cancelled", + "description": "Windows native plugin UI", + "platforms": [ + "windows" + ] + }, + { + "id": "pluginNative.timeout", + "description": "Windows native plugin UI", + "platforms": [ + "windows" + ] + }, + { + "id": "pluginNative.invalidData", + "description": "Windows native plugin UI", + "platforms": [ + "windows" + ] + }, + { + "id": "pluginNative.info", + "description": "Windows native plugin UI", + "platforms": [ + "windows" ], - "macos": { - "key": "Load %lld more", - "arguments": [ - "arg1" - ] - }, "parameters": { - "arg1": "integer" + "name": "string" } }, { - "id": "native.dataAccessRead", - "description": "Native UI: data breakpoint access type Read", + "id": "pluginNative.empty", + "description": "Windows native plugin UI", "platforms": [ - "macos" - ], - "macos": { - "key": "Read", - "arguments": [] - } + "windows" + ] }, { - "id": "native.dataAccessWrite", - "description": "Native UI: data breakpoint access type Write", + "id": "pluginNative.collapse", + "description": "Windows native plugin UI", "platforms": [ - "macos" + "windows" ], - "macos": { - "key": "Write", - "arguments": [] + "parameters": { + "name": "string" } }, { - "id": "native.dataAccessReadWrite", - "description": "Native UI: data breakpoint access type Read/Write", + "id": "pluginNative.expand", + "description": "Windows native plugin UI", "platforms": [ - "macos" + "windows" ], - "macos": { - "key": "Read/Write", - "arguments": [] + "parameters": { + "name": "string" } }, { - "id": "native.pauseTheProgramToRunDebuggerCommands", - "description": "Native UI: Pause the program to run debugger commands", + "id": "vcs.history.read", + "description": "Windows read-only plugin VCS history", "platforms": [ - "macos" - ], - "macos": { - "key": "Pause the program to run debugger commands", - "arguments": [] - } + "windows" + ] }, { - "id": "native.startADebugSessionToRunDebuggerCommands", - "description": "Native UI: Start a debug session to run debugger commands", + "id": "vcs.history.compareBase", + "description": "Windows read-only plugin VCS baseline comparison", "platforms": [ - "macos" - ], - "macos": { - "key": "Start a debug session to run debugger commands", - "arguments": [] - } + "windows" + ] }, { - "id": "native.enterADebuggerCommandTabToComplete", - "description": "Native UI: Enter a debugger command (Tab to complete)", + "id": "vcs.history.readFile", + "description": "Windows read-only plugin file history", "platforms": [ - "macos" - ], - "macos": { - "key": "Enter a debugger command (Tab to complete)", - "arguments": [] - } + "windows" + ] }, { - "id": "native.debuggerCommandInput", - "description": "Native UI: Debugger command input", + "id": "vcs.history.loading", + "description": "Windows read-only plugin VCS history", "platforms": [ - "macos" - ], - "macos": { - "key": "Debugger command input", - "arguments": [] - } + "windows" + ] }, { - "id": "native.debuggerCommandSuggestions", - "description": "Native UI: Debugger command suggestions", + "id": "vcs.history.stopping", + "description": "Windows read-only plugin VCS history", "platforms": [ - "macos" - ], - "macos": { - "key": "Debugger command suggestions", - "arguments": [] - } + "windows" + ] }, { - "id": "native.debugConsoleSuggestionKeys", - "description": "Native UI: ↑↓ Select · Tab Fill · Enter Run · Esc Close", + "id": "vcs.history.cleanupFailed", + "description": "Windows read-only plugin VCS history", "platforms": [ - "macos" - ], - "macos": { - "key": "↑↓ Select · Tab Fill · Enter Run · Esc Close", - "arguments": [] - } + "windows" + ] }, { - "id": "native.noProgramToDebugWasFoundBuildItFirstOrChooseOneInTheSettingOfLookedIn", - "description": "Native UI: No program to debug was found. Build it first, or choose one in the “%@” setting of %@. Looked in: %@", + "id": "vcs.history.failed", + "description": "Windows read-only plugin VCS history", "platforms": [ - "macos" - ], - "macos": { - "key": "No program to debug was found. Build it first, or choose one in the “%@” setting of %@. Looked in: %@", - "arguments": [ - "arg1", - "arg2", - "arg3" - ] - }, - "parameters": { - "arg1": "string", - "arg2": "string", - "arg3": "string" - } + "windows" + ] }, { - "id": "native.noProgramToDebugWasFoundBuildItFirstOrChooseOneInTheSettingOf", - "description": "Native UI: No program to debug was found. Build it first, or choose one in the “%@” setting of %@.", + "id": "vcs.history.entries", + "description": "Windows read-only plugin VCS history", "platforms": [ - "macos" - ], - "macos": { - "key": "No program to debug was found. Build it first, or choose one in the “%@” setting of %@.", - "arguments": [ - "arg1", - "arg2" - ] - }, - "parameters": { - "arg1": "string", - "arg2": "string" - } + "windows" + ] }, { - "id": "native.theProgramChosenInTheSettingOfIsMissingOrNotExecutable", - "description": "Native UI: The program chosen in the “%@” setting of %@ is missing or not executable: %@", + "id": "vcs.history.noMessage", + "description": "Windows read-only plugin VCS history", "platforms": [ - "macos" - ], - "macos": { - "key": "The program chosen in the “%@” setting of %@ is missing or not executable: %@", - "arguments": [ - "arg1", - "arg2", - "arg3" - ] - }, - "parameters": { - "arg1": "string", - "arg2": "string", - "arg3": "string" - } + "windows" + ] }, { - "id": "native.foundAutomatically", - "description": "Native UI: Found automatically", + "id": "vcs.history.more", + "description": "Windows read-only plugin VCS history", "platforms": [ - "macos" - ], - "macos": { - "key": "Found automatically", - "arguments": [] - } + "windows" + ] }, { - "id": "native.lookedIn", - "description": "Native UI: Looked in: %@", + "id": "vcs.history.limit", + "description": "Windows read-only plugin VCS history", "platforms": [ - "macos" + "windows" + ] + }, + { + "id": "vcs.history.empty", + "description": "Windows read-only plugin VCS history", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.history.compareTitle", + "description": "Windows read-only plugin VCS baseline comparison", + "platforms": [ + "windows" ], - "macos": { - "key": "Looked in: %@", - "arguments": [ - "arg1" - ] - }, "parameters": { - "arg1": "string" + "file": "string" } }, { - "id": "native.theChosenFileIsMissingOrNotExecutable", - "description": "Native UI: The chosen file is missing or not executable.", + "id": "vcs.actions.checkin", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" - ], - "macos": { - "key": "The chosen file is missing or not executable.", - "arguments": [] - } + "windows" + ] }, { - "id": "native.noPlaceToLookYetOpenTheFileYouWantToDebugOrChooseTheProgramYourself", - "description": "Native UI: No place to look yet. Open the file you want to debug, or choose the program yourself.", + "id": "vcs.actions.checkinRemote", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" - ], - "macos": { - "key": "No place to look yet. Open the file you want to debug, or choose the program yourself.", - "arguments": [] - } + "windows" + ] }, { - "id": "native.thePluginDoesNotProvideTheDebugAdapter", - "description": "Native UI: The plugin does not provide the debug adapter %@.", + "id": "vcs.actions.rollback", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" - ], - "macos": { - "key": "The plugin does not provide the debug adapter %@.", - "arguments": [ - "arg1" - ] - }, - "parameters": { - "arg1": "string" - } + "windows" + ] }, { - "id": "native.theDebugAdapterNamesNoLanguageServerToHostIt", - "description": "Native UI: The debug adapter %@ names no language server to host it.", + "id": "vcs.actions.update", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" - ], - "macos": { - "key": "The debug adapter %@ names no language server to host it.", - "arguments": [ - "arg1" - ] - }, - "parameters": { - "arg1": "string" - } + "windows" + ] }, { - "id": "native.theDebugAdapterUsesTheTransportWhichLitheCannotStartYet", - "description": "Native UI: The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported.", + "id": "vcs.actions.message", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" + "windows" + ] + }, + { + "id": "vcs.actions.cancel", + "description": "Windows snapshot-bound VCS selection and confirmation", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.actions.confirm", + "description": "Windows snapshot-bound VCS selection and confirmation", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.actions.count", + "description": "Windows snapshot-bound VCS selection and confirmation", + "platforms": [ + "windows" ], - "macos": { - "key": "The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported.", - "arguments": [ - "arg1", - "arg2" - ] - }, "parameters": { - "arg1": "string", - "arg2": "string" + "count": "integer" } }, { - "id": "native.theDebugAdapterMustLaunchAPackagedExecutableOrASystemCommand", - "description": "Native UI: The debug adapter %@ must launch a packaged executable or a system command.", + "id": "vcs.actions.running", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" + "windows" + ] + }, + { + "id": "vcs.actions.completed", + "description": "Windows snapshot-bound VCS selection and confirmation", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.actions.unknown", + "description": "Windows snapshot-bound VCS selection and confirmation", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.actions.conflicts", + "description": "Windows snapshot-bound VCS selection and confirmation", + "platforms": [ + "windows" ], - "macos": { - "key": "The debug adapter %@ must launch a packaged executable or a system command.", - "arguments": [ - "arg1" - ] - }, "parameters": { - "arg1": "string" + "count": "integer" } }, { - "id": "debug.tcp.startupTimedOut", - "description": "Debug adapter TCP lifecycle and connection consent", + "id": "vcs.actions.risk.checkin", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" - ], - "macos": { - "key": "The debug adapter connection timed out.", - "arguments": [] - } + "windows" + ] }, { - "id": "debug.tcp.writeTimedOut", - "description": "Debug adapter TCP lifecycle and connection consent", + "id": "vcs.actions.risk.rollback", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" - ], - "macos": { - "key": "The debug adapter did not accept outgoing data in time.", - "arguments": [] - } + "windows" + ] }, { - "id": "debug.tcp.bufferFull", - "description": "Debug adapter TCP lifecycle and connection consent", + "id": "vcs.actions.risk.update", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" - ], - "macos": { - "key": "The debug adapter outgoing buffer is full.", - "arguments": [] - } + "windows" + ] }, { - "id": "debug.tcp.invalidEndpoint", - "description": "Debug adapter TCP lifecycle and connection consent", + "id": "vcs.actions.error.vcs.authentication.failed", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" - ], - "macos": { - "key": "Enter a valid debug adapter host and a port between 1 and 65535.", - "arguments": [] - } + "windows" + ] }, { - "id": "debug.tcp.declined", - "description": "Debug adapter TCP lifecycle and connection consent", + "id": "vcs.actions.error.vcs.conflict", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" - ], - "macos": { - "key": "The debug adapter connection was not approved.", - "arguments": [] - } + "windows" + ] }, { - "id": "debug.tcp.confirmTitle", - "description": "Debug adapter TCP lifecycle and connection consent", + "id": "vcs.actions.error.vcs.outOfDate", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" - ], - "macos": { - "key": "Connect to an external debug adapter?", - "arguments": [] - } + "windows" + ] }, { - "id": "debug.tcp.confirmBody", - "description": "Debug adapter TCP lifecycle and connection consent", + "id": "vcs.actions.error.vcs.tool.unavailable", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" - ], - "macos": { - "key": "The debug adapter at %@ can access your debugging data. Plain TCP is not encrypted or authenticated. Connect only over a trusted network or a tunnel you configured.", - "arguments": [ - "arg1" - ] - }, - "parameters": { - "arg1": "string" - } + "windows" + ] }, { - "id": "debug.tcp.menu", - "description": "External DAP TCP connection dialog", + "id": "vcs.actions.error.vcs.workingCopy.locked", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" - ], - "macos": { - "key": "Connect to Debug Adapter…", - "arguments": [] - } + "windows" + ] }, { - "id": "debug.tcp.title", - "description": "External DAP TCP connection dialog", + "id": "vcs.actions.error.vcs.workingCopy.unsupported", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" - ], - "macos": { - "key": "Connect to Debug Adapter", - "arguments": [] - } + "windows" + ] }, { - "id": "debug.tcp.name", - "description": "External DAP TCP connection dialog", + "id": "vcs.actions.error.vcs.capability.unsupported", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" - ], - "macos": { - "key": "External Debug Adapter", - "arguments": [] - } + "windows" + ] }, { - "id": "debug.tcp.help", - "description": "External DAP TCP connection dialog", + "id": "vcs.actions.error.vcs.provider.pathUnavailable", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" - ], - "macos": { - "key": "Connect to an already running DAP server using the active file's installed debugger. This is not a JVM JDWP or gdbserver port. No proxy or tunnel is created.", - "arguments": [] - } + "windows" + ] }, { - "id": "debug.tcp.request", - "description": "External DAP TCP connection dialog", + "id": "vcs.actions.error.requestTimeout", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" - ], - "macos": { - "key": "Request", - "arguments": [] - } + "windows" + ] }, { - "id": "debug.tcp.arguments", - "description": "External DAP TCP connection dialog", + "id": "vcs.actions.error.requestCancelled", + "description": "Windows snapshot-bound VCS selection and confirmation", "platforms": [ - "macos" - ], - "macos": { - "key": "Adapter Arguments (JSON)", - "arguments": [] - } + "windows" + ] }, { - "id": "debug.tcp.argumentsHelp", - "description": "External DAP TCP connection dialog", + "id": "vcs.actions.wholeCopy", + "description": "Windows VCS update confirmation scope", "platforms": [ - "macos" - ], - "macos": { - "key": "Enter the launch or attach arguments required by this adapter, including its source path mappings if needed. These are separate from the DAP server's TCP address.", - "arguments": [] - } + "windows" + ] }, { - "id": "debug.tcp.invalidArguments", - "description": "External DAP TCP connection dialog", + "id": "native.theLanguageServiceThatHostsTheDebugAdapterIsUnavailable", + "description": "Native UI: The language service that hosts the debug adapter is unavailable.", "platforms": [ "macos" ], "macos": { - "key": "Enter a JSON object of adapter arguments no larger than 256 KiB.", + "key": "The language service that hosts the debug adapter is unavailable.", "arguments": [] } }, { - "id": "debug.tcp.attachRequest", - "description": "External DAP request kind", + "id": "native.theDebugAdapterConnectionIsStopped", + "description": "Native UI: The debug adapter connection is stopped.", "platforms": [ "macos" ], "macos": { - "key": "Attach Request", + "key": "The debug adapter connection is stopped.", "arguments": [] } }, { - "id": "debug.tcp.launchRequest", - "description": "External DAP request kind", + "id": "native.theLanguageServerReturnedAnInvalidTcpPortForItsDebugAdapter", + "description": "Native UI: The %@ language server returned an invalid TCP port for its debug adapter.", "platforms": [ "macos" ], "macos": { - "key": "Launch Request", - "arguments": [] + "key": "The %@ language server returned an invalid TCP port for its debug adapter.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" } }, { - "id": "debug.tcp.sessionActive", - "description": "External DAP connect sheet failures", + "id": "native.theLanguageServerOfThisPluginIsNotAvailable", + "description": "Native UI: The %@ language server of this plugin is not available.", "platforms": [ "macos" ], "macos": { - "key": "Stop the current debug session before connecting to a debug adapter.", - "arguments": [] + "key": "The %@ language server of this plugin is not available.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" } }, { - "id": "debug.tcp.notStarted", - "description": "External DAP connect sheet failures", + "id": "native.theDebugAdapterNamesNoLanguageServerToHostIt", + "description": "Native UI: The debug adapter %@ names no language server to host it.", "platforms": [ "macos" ], "macos": { - "key": "The debug adapter connection could not be started.", - "arguments": [] + "key": "The debug adapter %@ names no language server to host it.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" } }, { @@ -90033,6 +93017,347 @@ "arguments": [] } }, + { + "id": "vcs.actions.resolve", + "description": "Windows VCS action: vcs.actions.resolve", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.actions.cleanup", + "description": "Windows VCS action: vcs.actions.cleanup", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.actions.risk.resolve", + "description": "Windows VCS action: vcs.actions.risk.resolve", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.actions.risk.cleanup", + "description": "Windows VCS action: vcs.actions.risk.cleanup", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.actions.add", + "description": "Windows VCS file operation: vcs.actions.add", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.actions.delete", + "description": "Windows VCS file operation: vcs.actions.delete", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.actions.risk.add", + "description": "Windows VCS file operation: vcs.actions.risk.add", + "platforms": [ + "windows" + ] + }, + { + "id": "vcs.actions.risk.delete", + "description": "Windows VCS file operation: vcs.actions.risk.delete", + "platforms": [ + "windows" + ] + }, + { + "id": "debug.tcp.startupTimedOut", + "description": "Debug adapter TCP lifecycle and connection consent", + "platforms": [ + "macos" + ], + "macos": { + "key": "The debug adapter connection timed out.", + "arguments": [] + } + }, + { + "id": "debug.tcp.writeTimedOut", + "description": "Debug adapter TCP lifecycle and connection consent", + "platforms": [ + "macos" + ], + "macos": { + "key": "The debug adapter did not accept outgoing data in time.", + "arguments": [] + } + }, + { + "id": "debug.tcp.bufferFull", + "description": "Debug adapter TCP lifecycle and connection consent", + "platforms": [ + "macos" + ], + "macos": { + "key": "The debug adapter outgoing buffer is full.", + "arguments": [] + } + }, + { + "id": "debug.tcp.invalidEndpoint", + "description": "Debug adapter TCP lifecycle and connection consent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a valid debug adapter host and a port between 1 and 65535.", + "arguments": [] + } + }, + { + "id": "debug.tcp.declined", + "description": "Debug adapter TCP lifecycle and connection consent", + "platforms": [ + "macos" + ], + "macos": { + "key": "The debug adapter connection was not approved.", + "arguments": [] + } + }, + { + "id": "debug.tcp.confirmTitle", + "description": "Debug adapter TCP lifecycle and connection consent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connect to an external debug adapter?", + "arguments": [] + } + }, + { + "id": "debug.tcp.confirmBody", + "description": "Debug adapter TCP lifecycle and connection consent", + "platforms": [ + "macos" + ], + "macos": { + "key": "The debug adapter at %@ can access your debugging data. Plain TCP is not encrypted or authenticated. Connect only over a trusted network or a tunnel you configured.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "debug.tcp.menu", + "description": "External DAP TCP connection dialog", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connect to Debug Adapter…", + "arguments": [] + } + }, + { + "id": "debug.tcp.title", + "description": "External DAP TCP connection dialog", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connect to Debug Adapter", + "arguments": [] + } + }, + { + "id": "debug.tcp.name", + "description": "External DAP TCP connection dialog", + "platforms": [ + "macos" + ], + "macos": { + "key": "External Debug Adapter", + "arguments": [] + } + }, + { + "id": "debug.tcp.help", + "description": "External DAP TCP connection dialog", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connect to an already running DAP server using the active file's installed debugger. This is not a JVM JDWP or gdbserver port. No proxy or tunnel is created.", + "arguments": [] + } + }, + { + "id": "debug.tcp.request", + "description": "External DAP TCP connection dialog", + "platforms": [ + "macos" + ], + "macos": { + "key": "Request", + "arguments": [] + } + }, + { + "id": "debug.tcp.arguments", + "description": "External DAP TCP connection dialog", + "platforms": [ + "macos" + ], + "macos": { + "key": "Adapter Arguments (JSON)", + "arguments": [] + } + }, + { + "id": "debug.tcp.argumentsHelp", + "description": "External DAP TCP connection dialog", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter the launch or attach arguments required by this adapter, including its source path mappings if needed. These are separate from the DAP server's TCP address.", + "arguments": [] + } + }, + { + "id": "debug.tcp.invalidArguments", + "description": "External DAP TCP connection dialog", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a JSON object of adapter arguments no larger than 256 KiB.", + "arguments": [] + } + }, + { + "id": "debug.tcp.attachRequest", + "description": "External DAP request kind", + "platforms": [ + "macos" + ], + "macos": { + "key": "Attach Request", + "arguments": [] + } + }, + { + "id": "debug.tcp.launchRequest", + "description": "External DAP request kind", + "platforms": [ + "macos" + ], + "macos": { + "key": "Launch Request", + "arguments": [] + } + }, + { + "id": "debug.tcp.sessionActive", + "description": "External DAP connect sheet failures", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop the current debug session before connecting to a debug adapter.", + "arguments": [] + } + }, + { + "id": "debug.tcp.notStarted", + "description": "External DAP connect sheet failures", + "platforms": [ + "macos" + ], + "macos": { + "key": "The debug adapter connection could not be started.", + "arguments": [] + } + }, + { + "id": "native.hideCommitDetails", + "description": "Native UI: Hide commit details", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide commit details", + "arguments": [] + } + }, + { + "id": "native.sideBySideView", + "description": "Native UI: Side-by-side view", + "platforms": [ + "macos" + ], + "macos": { + "key": "Side-by-side view", + "arguments": [] + } + }, + { + "id": "settings.mcp.error.expired", + "description": "Windows IDE MCP request error: expired", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mcp.error.request", + "description": "Windows IDE MCP request error: request", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mcp.error.transport", + "description": "Windows IDE MCP request error: transport", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mcp.error.undelivered", + "description": "Windows IDE MCP request error: undelivered", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mcp.status.active", + "description": "Windows IDE MCP request status: active", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mcp.status.failed", + "description": "Windows IDE MCP request status: failed", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mcp.status.waiting", + "description": "Windows IDE MCP request status: waiting", + "platforms": [ + "windows" + ] + }, { "id": "plugins.java.installNotice", "description": "macOS Java Support independent distribution installation entry", diff --git a/shared/platform-feature-matrix/features/build-system-plugins.json b/shared/platform-feature-matrix/features/build-system-plugins.json index 1388c75971..ff0b3060f9 100644 --- a/shared/platform-feature-matrix/features/build-system-plugins.json +++ b/shared/platform-feature-matrix/features/build-system-plugins.json @@ -63,11 +63,63 @@ "evidence": [ "rust/lithe-core/src/build", "shared/contracts/plugins/v1/fixtures/build-plans.json", - "shared/contracts/plugins/v1/fixtures/build-provider.plugin.json" + "shared/contracts/plugins/v1/fixtures/build-provider.plugin.json", + "windows/tauri/src-tauri/src/plugin_host/build_registry.rs", + "windows/tauri/src-tauri/src/plugin_host/build.rs", + "windows/tauri/src-tauri/src/plugin_host/build_tests.rs", + "windows/tauri/src/features/build-systems", + "windows/tauri/src-tauri/src/plugin_host/build_resolver.rs", + "windows/tauri/src-tauri/src/plugin_host/build_resolver_tests.rs", + "windows/tauri/src-tauri/src/plugin_host/build/operation.rs", + "windows/tauri/src-tauri/src/plugin_host/build/host.rs", + "windows/tauri/src-tauri/src/plugin_host/build/process.rs", + "windows/tauri/src-tauri/src/plugin_host/build/tests.rs", + "windows/tauri/src/features/build-systems/output/build-output-types.ts", + "windows/tauri/src/features/build-systems/output/build-output-store.test.ts", + "windows/tauri/src/features/build-systems/output/build-output-path.test.ts", + "windows/tauri/src/features/build-systems/output/components/build-output-pane.test.tsx", + "windows/tauri/src/features/build-systems/output/components/build-output-log.test.tsx", + "windows/tauri/src/features/build-systems/output/components/build-output-problems.test.tsx", + "windows/tauri/src/features/build-systems/output/components/build-output-tests.test.tsx", + "windows/tauri/src-tauri/src/main.rs", + "windows/tauri/src-tauri/src/plugin_host/build/run.rs", + "windows/tauri/src-tauri/src/plugin_host/build/output.rs", + "windows/tauri/src-tauri/src/plugin_host/build/run_tests.rs", + "windows/tauri/src/features/build-systems/components/build-model-controls.tsx", + "windows/tauri/src/features/build-systems/components/build-model-controls.test.tsx", + "windows/tauri/src/features/build-systems/build-run-native.ts", + "windows/tauri/src/features/build-systems/build-run-session.ts", + "windows/tauri/src/features/build-systems/components/build-run-controls.tsx", + "windows/tauri/src/features/build-systems/build-run-native.test.ts", + "windows/tauri/src/features/build-systems/build-run-session.test.ts", + "windows/tauri/src/features/build-systems/components/build-run-controls.test.tsx", + "windows/tauri/src-tauri/src/plugin_host/build/host_integration.rs", + "windows/tauri/src-tauri/src/plugin_host/build/host_integration_tests.rs", + "windows/tauri/src-tauri/src/plugin_host/build/dependencies.rs", + "windows/tauri/src-tauri/src/plugin_host/build/dependencies_tests.rs", + "windows/tauri/src-tauri/src/plugin_host/build/language_import.rs", + "windows/tauri/src-tauri/src/plugin_host/build/language_import_tests.rs", + "windows/tauri/src/features/build-systems/build-dependencies-session.test.ts", + "windows/tauri/src/features/build-systems/components/build-dependencies.test.tsx", + "windows/tauri/src/features/build-systems/build-language-import.test.ts", + "windows/tauri/src-tauri/src/plugin_host/provider.rs", + "windows/tauri/src-tauri/src/plugin_host/provider/tests.rs", + "scripts/plugin-host-acceptance/build_fixture.py", + "scripts/plugin-host-acceptance/build_acceptance.py", + "scripts/plugin-host-acceptance/build_acceptance_test.py", + "windows/tauri/src-tauri/src/plugin_host/build/provider.rs", + "windows/tauri/src-tauri/src/plugin_host/build/provider_tests.rs", + "windows/tauri/src/features/build-systems/build-provider-language.ts", + "windows/tauri/src/features/build-systems/build-provider-review.test.ts", + "windows/tauri/src/features/build-systems/components/build-provider-review.tsx", + "windows/tauri/src/platform/lsp-core-adapter.ts", + "windows/tauri/src/platform/lsp-core-adapter.test.ts" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "partial", + "verificationStatus": "pending" }, "owner": "Extensions", - "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。命令模型 run 配置尚未接入;buildProviders 2 已接入原生任务 lithe/build/execute,复用工作区进程并校验协议版本、进度/结果、取消/超时和旧结果隔离,真实安装包/工具工作流仍待验收,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。;buildProviders 3 增加原生测试执行 lithe/build/test,筛选参数保持类型化,报告复用 JUnit/标准结果解析;旧协议明确拒绝测试,报告解析期间配置失效会取消操作并丢弃迟到结果。原生树已接入按需测试发现,Core 校验完整清单并保留模块身份,运行前重查当前清单;配置/源码/包变化、停用/重开使缓存或进行中的发现失效。真实 Maven 模块编译与单方法测试已有生产 macOS Provider 会话证据;Windows 工作流仍待验收。;buildProviders 4 应用目标准备接入原生 Build 树,构建进度在 Build Output 展示,验证后转交 RunService 解析工具与启动/停止应用;准备期间源码、选项、工作区或停用变化拒绝迟到启动,旧操作 ID 不得停止替换进程;Core 拒绝绝对可执行路径、越界目录/符号链接及超限参数环境。服务编排、调试与 Java/Maven 消费者迁移尚未完成;真实插件/发行包和 Windows 工作流仍待验收。;Run v2 本地层显式保存 Provider 选择器,参数保持原始数组;无 generated.json 也能读回/显示,重复保存保留 ID/其他配置/项目环境;重启重新进入当前 Provider,停止与晚到结果按身份隔离。原生入口保存工作区文档后再准备,Run 窗口在未生成旧配置时仍显示 Provider 输出。;Agent 入口回归从真实本地配置启动,验证准备到进程退出保持公开操作 ID、准备中停止与重启重新准备,旧 ID 不能停止替换操作;Provider 与进程为可控替身,真实插件端到端验收仍待完成。;配套独立仓库 Node/Java SDK 源码补齐协议 2/3/4 的处理器、版本准入与进度;无页面示例经真实 Core 验证非零构建退出码、精确筛选、运行前构建及准备计划、并行工作区、取消工具清理和完整包不变,另保留锁定旧契约的兼容场景与 TypeScript 调用方编译检查。SDK 尚未发布,真实 Maven/JDT 与 Windows 验收仍待完成。 原生 Debug:运行声明引用同包适配器,点击 Build 树 Debug 或保存配置的 Debug 后先保存并构建,验证程序路径/参数/环境交给注册表;停止、源码或配置变化、模型重载、插件停用与工作区切换均丢弃过期结果,解码后的文件别名变化和不可执行文件在平台交接前拒绝。Node/Java SDK 进程的准备协议与 Core 解码有端到端覆盖;真实适配器、Java/JDT、跨插件协作和 Windows 尚未验收。 Maven 独立插件的本地增量已经删除生命周期/插件目标/自定义目标/测试的命令模板,改由 Provider 3 执行;真实 Core C ABI + Java 后端 + Maven 三模块工程验证模块编译、单方法 Surefire 报告和模型共享,受控进程回归覆盖取消、非零退出和输出超限。插件与新版 SDK 已分别提交 Draft PR #7/#4,尚未发布,正式 SDK 锁定仍待更新;生产 macOS Provider 会话已经执行模型、编译和单方法测试,包 SHA-256/权限保持不变;显式 SDK CI 在 Java 8 上通过当前与 3.6.3 Maven 的 55 项开发检查(各自包括 3 项打包);旧 MavenService、Agent/设置/Run/Java 迁移、正式包、完整 macOS UI 与 Windows 验收未完成,保持 partial/pending。 真实原生调试集成:公开 Node SDK Provider 用 Clang 编译 C 程序,经生产 macOS 会话、Core 准备、Registry 明确身份、生产 stdio 传输和 LLDB 命中源码断点、读取 main 栈帧、保留空格参数后继续并退出码 0;包 SHA-256/权限不变,所有本次进程回收。测试显式选择 SDK/工具,完整 UI、正式发行与 Windows 尚未验收,verification 保持 pending。 Build 树应用从文档保存阶段起持有同一可取消运行 ID;保存 gate 下启动新任务/测试或停止、切换工作区、选项/源码/模型/插件失效后,旧保存返回不得启动 Provider、替换新运行或清空新输出;保存失败不启动,成功后才准备。 原生视图隔离回归用真实 Core 与受控 Provider 数据检查深浅主题的测试发现、Debug 入口和 Run 输出;截图仅验证该视图状态,不代替完整发行包或 Windows 实机验收。" + "notes": "Windows 内部 registry 复用 Core 静态检测、选项和指纹;hostIntegration 的被动模型继续使用既有 project::maven::scan,与旧 maven.scan 同一事实来源,新增薄适配、精确实例根/选项、完整包快照复验、刷新票据和 NativeLease 生命周期保护,不引入 POM 解析器。显式命令模型加载/重载与 task/test UI 已接真实原生 prepare/approve/execute/cancel 和 window-targeted 输出,执行前保存与同步,按完整模型 ID/tags 选择,复用 output 组件展示诊断/测试和源码跳转,取消失败保留原 owner。文件变更和执行前的原生指纹检查会使旧审批失效,自身保存且指纹未变不会误取消;订阅先于执行,快速终态等待输出排空。原生执行仍由 Core 计划/解码和共享 Job Object owner 负责。能力开关 buildSystems/executableResolution/buildProviders 继续关闭;依赖加载已接 prepare/审批/共享执行器/Core decodeDependencies/通用依赖树,支持 stdout、操作独占 plugin-state 文件和 Maven hostIntegration,Core 命令结果按正式信封读取;取消或上下文失效后不再等待已关闭订阅的事件。Java 启动和预热复用 Core languageImport,精确实例、有效选项和完整包快照在 JDT 准备后再次复验,Maven 经已有会话入口热更新,Gradle 在下次启动生效,无声明式归属时保留兼容来源。网页桥已接被动 instances/setOptions,完整 refresh/run/dependencies、应用/服务运行与 Provider 仍待后续,整体 partial/pending。模型临时文件仍为操作独占的可写 plugin-state,不进入发行资源或复用缓存。 Windows R6 增加 Provider 会话基础:Core 原生 owner 在首次 initialize 写入前登记,按窗口/工作区代次/包/实例隔离,专属进程与同插件共享语言服务经已有 Core 请求及取消通道;撤销与发布串行校验,清理失败保留 owner 和包租约。尚未接入 build.* Provider 调用与审批 UI。真实包验收增加六种受控非 Provider 构建夹具、进程树取消及发行资源哈希校验;辅助测试不能代替产品验收。buildSystems/executableResolution/buildProviders/executionProviders 仍关闭,整体 partial/pending。 Windows R7 已接真实 build.modelPlan/dependencyPlan 的 Provider 来源到现有原生 prepare/confirm/ProviderBinding.request 和 Core decodeModel/decodeDependencies;审批展示冻结程序、参数、运行时要求和项目代码/网络风险,已有编辑器 LSP owner 负责同插件共享来源的去重启动与崩溃后重试,原生不启动第二个共享服务器。专属初始化最后等待者取消会等待清理;停止失败保留 Provider owner、包租约和 build busy。工作区代次、设置、包与实例目录在请求和发布前后复验。能力仍关闭,Rust/Release 和真实包验收待主窗口统一运行。 R7 共享语言启动票据仅在用户批准后的原生 authorize-provider-language 调用发放;票据绑定仍存活的 build review,取消后不可启动。专属与共享初始化分别保留等待者计数,最后一个仅构建等待者取消会收回它引入的语言进程,编辑器或其他构建等待者仍持有的会话继续使用。", + "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。命令模型 run 配置尚未接入;buildProviders 2 已接入原生任务 lithe/build/execute,复用工作区进程并校验协议版本、进度/结果、取消/超时和旧结果隔离,真实安装包/工具工作流仍待验收,Provider 无操作系统级沙盒;自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。;buildProviders 3 增加原生测试执行 lithe/build/test,筛选参数保持类型化,报告复用 JUnit/标准结果解析;旧协议明确拒绝测试,报告解析期间配置失效会取消操作并丢弃迟到结果。原生树已接入按需测试发现,Core 校验完整清单并保留模块身份,运行前重查当前清单;配置/源码/包变化、停用/重开使缓存或进行中的发现失效。真实 Maven 模块编译与单方法测试已有生产 macOS Provider 会话证据;Windows 工作流仍待验收。;buildProviders 4 应用目标准备接入原生 Build 树,构建进度在 Build Output 展示,验证后转交 RunService 解析工具与启动/停止应用;准备期间源码、选项、工作区或停用变化拒绝迟到启动,旧操作 ID 不得停止替换进程;Core 拒绝绝对可执行路径、越界目录/符号链接及超限参数环境。服务编排、调试与 Java/Maven 消费者迁移尚未完成;真实插件/发行包和 Windows 工作流仍待验收。;Run v2 本地层显式保存 Provider 选择器,参数保持原始数组;无 generated.json 也能读回/显示,重复保存保留 ID/其他配置/项目环境;重启重新进入当前 Provider,停止与晚到结果按身份隔离。原生入口保存工作区文档后再准备,Run 窗口在未生成旧配置时仍显示 Provider 输出。;Agent 入口回归从真实本地配置启动,验证准备到进程退出保持公开操作 ID、准备中停止与重启重新准备,旧 ID 不能停止替换操作;Provider 与进程为可控替身,真实插件端到端验收仍待完成。;配套独立仓库 Node/Java SDK 源码补齐协议 2/3/4 的处理器、版本准入与进度;无页面示例经真实 Core 验证非零构建退出码、精确筛选、运行前构建及准备计划、并行工作区、取消工具清理和完整包不变,另保留锁定旧契约的兼容场景与 TypeScript 调用方编译检查。SDK 尚未发布,真实 Maven/JDT 与 Windows 验收仍待完成。 原生 Debug:运行声明引用同包适配器,点击 Build 树 Debug 或保存配置的 Debug 后先保存并构建,验证程序路径/参数/环境交给注册表;停止、源码或配置变化、模型重载、插件停用与工作区切换均丢弃过期结果,解码后的文件别名变化和不可执行文件在平台交接前拒绝。Node/Java SDK 进程的准备协议与 Core 解码有端到端覆盖;真实适配器、Java/JDT、跨插件协作和 Windows 尚未验收。 Maven 独立插件的本地增量已经删除生命周期/插件目标/自定义目标/测试的命令模板,改由 Provider 3 执行;真实 Core C ABI + Java 后端 + Maven 三模块工程验证模块编译、单方法 Surefire 报告和模型共享,受控进程回归覆盖取消、非零退出和输出超限。插件与新版 SDK 已分别提交 Draft PR #7/#4,尚未发布,正式 SDK 锁定仍待更新;生产 macOS Provider 会话已经执行模型、编译和单方法测试,包 SHA-256/权限保持不变;显式 SDK CI 在 Java 8 上通过当前与 3.6.3 Maven 的 55 项开发检查(各自包括 3 项打包);旧 MavenService、Agent/设置/Run/Java 迁移、正式包、完整 macOS UI 与 Windows 验收未完成,保持 partial/pending。 真实原生调试集成:公开 Node SDK Provider 用 Clang 编译 C 程序,经生产 macOS 会话、Core 准备、Registry 明确身份、生产 stdio 传输和 LLDB 命中源码断点、读取 main 栈帧、保留空格参数后继续并退出码 0;包 SHA-256/权限不变,所有本次进程回收。测试显式选择 SDK/工具,完整 UI、正式发行与 Windows 尚未验收,verification 保持 pending。 Build 树应用从文档保存阶段起持有同一可取消运行 ID;保存 gate 下启动新任务/测试或停止、切换工作区、选项/源码/模型/插件失效后,旧保存返回不得启动 Provider、替换新运行或清空新输出;保存失败不启动,成功后才准备。 原生视图隔离回归用真实 Core 与受控 Provider 数据检查深浅主题的测试发现、Debug 入口和 Run 输出;截图仅验证该视图状态,不代替完整发行包或 Windows 实机验收。 Windows:运行宿主 Rust 逐项计时,覆盖真实 Core/旧 Maven 后端模型根、选项、指纹、同版本替包、关闭/撤销和排队刷新失效,及已有 task/test 计划、报告时序、取消和进程树回收。前端每文件独立运行 run-native/session/controls、service/store/pane/model-controls 和 output 测试,覆盖真实文件事件与审批新鲜度、保存→同步→审批→订阅→执行、execute 前输出、快速完成、完整身份、取消失败重试、迟到订阅与诊断跳转。正式 Release/WebView2 构建和 LSP 插件链不替代尚未开放构建能力的真实 task/test 原生产品验收;三类模型、应用/服务、Provider/网页/导入完成前保持 pending。 R5:独立运行依赖 native/session/tree、Java import/launch/Maven hot update 和网页桥前端测试,宿主 Rust 使用真实 Core 依赖解码/导入回归;覆盖陈旧模型、同版本替包、关闭/切换与迟到结果、取消失败保留 owner、stdout/outputFile、完整选择和通用依赖树。能力仍关闭,未执行真实构建插件包产品验收,不能将组件或宿主测试当作完整交付。 R7:逐项运行 Provider 审批校验与共享语言调用前后取消的 Bun 测试;宿主增加真实 Core 两种来源的模型/依赖计划与解码、共享等待者取消隔离、初始化等待者隔离回归。主窗口需运行 WindowsRust 定时套件、现有 model/dependencies/LSP 前端集成与正式 Release,并用真实 Provider 包验证初始化、取消、崩溃后重试、设置/包失效、停止失败阻止替换。 Core 构建检测兼容回归:普通文件根和不存在根返回空实例,真实目录读取/扫描失败仍拒绝;共享扫描器保持严格语义,Windows BuildRegistry 的真实 Core 回归与 Core command/错误传播回归分别验证,不开放 Windows buildSystems/buildProviders。", + "lastReviewed": "2026-10-29" } diff --git a/shared/platform-feature-matrix/features/declarative-native-plugin-ui.json b/shared/platform-feature-matrix/features/declarative-native-plugin-ui.json index 04288e54f5..25b1179a49 100644 --- a/shared/platform-feature-matrix/features/declarative-native-plugin-ui.json +++ b/shared/platform-feature-matrix/features/declarative-native-plugin-ui.json @@ -30,12 +30,60 @@ ] }, "windows": { - "implementationStatus": "missing", - "verificationStatus": "not-applicable", + "implementationStatus": "partial", + "verificationStatus": "pending", "evidence": [ - "shared/contracts/plugins/v1/README.md" + "shared/contracts/plugins/v1/README.md", + "shared/contracts/plugins/v1/native-ui.schema.json", + "shared/contracts/plugins/v1/fixtures/native-panel.plugin.json", + "shared/contracts/plugins/v1/fixtures/native-form.plugin.json", + "shared/contracts/plugins/v1/fixtures/native-controls.plugin.json", + "windows/tauri/src/features/plugins/native-ui/native-ui.types.ts", + "windows/tauri/src/features/plugins/native-ui/native-panel-session.ts", + "windows/tauri/src/features/plugins/native-ui/native-panel.tsx", + "windows/tauri/src/features/plugins/native-ui/native-panel-session.test.ts", + "windows/tauri/src/features/plugins/native-ui/native-ui-core.ts", + "windows/tauri/src/features/plugins/native-ui/native-contributions.ts", + "windows/tauri/src/features/plugins/native-ui/native-tool-window.tsx", + "windows/tauri/src/features/plugins/declarative-plugin-ui.ts", + "windows/tauri/src-tauri/src/plugin_host/native_ui/mod.rs", + "windows/tauri/src/features/plugins/native-ui/native-panel.test.tsx", + "windows/tauri/src/features/plugins/native-ui/native-panel-state.ts", + "windows/tauri/src-tauri/src/plugin_host/functions/native.rs", + "windows/tauri/src-tauri/src/plugin_host/functions/native/tests.rs", + "windows/tauri/src/features/plugins/native-ui/native-functions.ts", + "windows/tauri/src/features/plugins/native-ui/native-functions.test.ts", + "windows/tauri/src/features/plugins/native-ui/native-contribution-button.tsx", + "windows/tauri/src/features/plugins/native-ui/native-docking.ts", + "windows/tauri/src/features/plugins/native-ui/native-tool-window-slot.tsx", + "windows/tauri/src/features/plugins/native-ui/native-docking.test.tsx", + "windows/tauri/src/features/layout/components/main-layout.tsx", + "windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx", + ".agents/notes/implemented/feature/2026-10-21-declarative-native-plugin-ui.md", + "windows/tauri/src/features/plugins/native-ui/native-main-layout.test.tsx", + "windows/tauri/src/features/plugins/native-ui/native-main-layout.test-support.tsx", + "windows/tauri/src/ui/info-tip.tsx", + "windows/tauri/src/ui/info-tip.test.tsx", + "windows/tauri/src/ui/plugin-host-icon.tsx", + "windows/tauri/src/ui/icons.test.tsx", + "windows/tauri/src/ui/search.tsx", + "windows/tauri/src/ui/input.tsx", + "windows/tauri/src/ui/search.test.tsx", + "windows/tauri/src/ui/button.tsx", + "scripts/plugin-host-acceptance/settings_functions_acceptance.py", + "windows/tauri/src/ui/context-menu.tsx", + "windows/tauri/src/features/plugins/native-ui/native-panel.browser-test.tsx", + "scripts/test-windows-native-plugin-ui-browser.mjs", + ".github/workflows/ci-windows.yml", + "scripts/plugin-host-acceptance/native_browser_supervisor.py", + "windows/tauri/src/features/plugins/native-ui/native-tree.browser-test.tsx", + "windows/tauri/src/features/plugins/native-ui/native-form-grid.browser-test.tsx", + "scripts/native-workbench-browser.mjs", + "windows/tauri/src/features/plugins/native-ui/native-workbench.browser-test.tsx", + "windows/tauri/src/features/plugins/native-ui/native-main-layout.test-boundaries.tsx" ] }, "owner": "Plugin Platform", - "verification": "运行 PluginNativeUITests、PluginWebBridgeTests、WorkbenchContributionHostTests 和 UI Lab 验收测试(从外部 lithe-plugin-docs 显式取得 SDK);macOS 加载 sample-build 示例,刷新、选择、展开及右键 Inspect,验证键盘、浅深色、失焦、隐藏重开、停靠布局恢复以及停用/关闭过程取消;Windows 必须拒绝 ui 3,后续独立适配并验收。原生产品视觉验收 pending。 已在真实 DEBUG 预览中使用磁盘安装示例验证节点加载、展开、Inspect、右键动作、方向键及隐藏重开恢复;修复原生入口初始为空时未出现内容的问题,增加完整父容器挂载回归。完整浅深色、停靠及停用中取消验收仍 pending。 独立 native-ui-lab 验收插件覆盖静态/动态树与列表、左右及底部位置、空数据、600 项长数据、增删改序、动作/数据错误、重复 ID 拒绝和可取消慢动作;运行 Node 验收测试并从打包后的 vendored SDK 再跑一次。完整人工验收由配套 README 指引,仍为 pending。 同一停靠槽在动态树、静态树、列表与空面板间往返切换,确认正确释放窗口会话,不能出现 disabled or already registered;检查标题栏高度、文字、无图标/边框/底线和隐藏动作。 三面板同时挂载,分别隐藏/重开,再全部移除并重建;确认会话所有者销毁后登记清空,旧句柄的释放不影响后续会话。 分隔布局新面板先挂载、旧面板后释放时,同一窗口原子移交会话;旧释放不关闭新句柄,不允许跨插件覆盖登记。 检查原生动作 host 图标的深浅主题工具栏色、原生16pt动作资源和描边,刷新/通用执行兼容回退、面板内悬停显露与面板外隐藏,标题仅用于 tooltip/辅助功能名称。 表单验收:输入中文/Emoji、占位提示与回车提交,选择器键盘/鼠标与 Escape,复选框文字/键盘切换,三类禁用、执行中禁用、所有当前值传入既有函数、隐藏重开及工作区隔离、窄面板滚动和深浅主题;按钮声明复用公共样式。 栅格验收:宽面板每行两个字段,跨列布局能力正确对齐,窄于640pt内容宽度回到单列,变宽恢复;不丢字段值/焦点、不水平溢出,旧无layout表单保持单列。 0.3.0验收:提示悬停/固定/键盘、搜索输入/清空/回车、多行编辑与按钮提交、分段选择与数值边界、主次按钮正常/禁用和content/fill宽度、参数类型/恢复/忙碌/取消/错误;运行公共按钮深浅外框像素回归。 0.3.1验收:现有设置/市场/Git/数据库搜索和插件的完整公共入口一致,清空及外部焦点不回归,禁用输入/按钮和忙碌状态下i提示可悬停/点击/键盘打开,值和动作仍保持禁用。 0.3.2:主次文字按钮复用preview统一样式,实际28pt高度、无外圈留白、短/长文案自然增长和6pt动作间距;窄面板纵向排列;旧fill声明仍可加载但不能拉伸按钮。" + "verification": "macOS:运行 PluginNativeUITests、PluginWebBridgeTests、WorkbenchContributionHostTests 和 UI Lab 验收测试;加载固定版本外部 lithe-plugin-docs sample-build,验证刷新、选择、展开、右键 Inspect、键盘、浅深色、失焦、隐藏重开和停用时取消。Windows:Frontend 逐项计时 harness 运行 native-panel-session.test.ts 和 native-panel.test.tsx,真实消费 native-panel/form/controls 三个共享 fixture,覆盖 600 行虚拟化长树、键盘选择、IME 提交、表单全部草稿在执行前交 Core 校验、失效/替包/关闭取消、隐藏重开、源失败重试和 64 KiB 按插件/宿主窗口/工作区累计预算;原生 typed adapter 运行 plugin_host::native_ui 回归,使用 plugins.nativeUI.validateData。完整 TypeScript、共享契约和 Windows x64 Release 已由主窗口 R11 统一集成通过。Windows 仍拒绝 ui:3 准入;生产工具窗口注入 typed NativeFunctionPort,复用设置/网页 Core 会话、原生审批与全局清理提示;原生登记从可信 catalog 绑定包/面板/窗口实例/工作区/唯一构建实例,禁止前端选择 function/program。Frontend 覆盖 load/action 审批拒绝、准备/审批中取消、旧结果、失败、原 owner 重试和三个真实 fixture 的组件到 typed port;全部公共字段的修改、IME、键盘及虚拟化均有组件回归。运行原生 plugin_host::functions::native 测试以验证 Core 全表单/加载结果和预算、同槽替换、原窗口与唯一实例;主窗口已完成原生编译与逐项计时验证。真实 Tauri 停靠、完整包及浅深色/焦点/窄窗口人工视觉验收仍 pending。 同槽打开顺序在首个异步步骤前固定;旧请求晚完成不能覆盖新句柄。成功句柄因设置/模型失效后,Retry 先成功清理原句柄再重登记;清理失败不允许绕过旧 owner。 主窗口 R12:36 个前端文件 325 项、Tauri 宿主最终 751 项通过及 4 项夹具跳过、Node 85 项通过及 1 项平台跳过;完整 TypeScript、共享契约、测试稳定性、Windows 边界、bundle 只读静态门禁和 Windows x64 Release 已通过。首轮两个新增夹具失败已修复并保留失败记录,最终受影响原生 208 项重验通过,并补最终 VCS 分类回归 65 项。真实安装 Provider/系统客户端与进程树、完整包、双窗口及 WebView2 人工验收、macOS 和 Windows arm64 仍 pending;受控测试与 x64 编译不能替代这些证据。 Windows R13:保留准入门控的原生渲染器与 typed NativeFunctionPort 已接入左/下/右三个既有工作台槽;右键移动保持单一面板所有者,隐藏/移动/同槽替换释放精确句柄,关闭核对包、工作区、选中贡献和位置后恢复入口焦点。位置覆盖仅当前宿主窗口/工作区内存,包替换隔离草稿,不宣称重启恢复或 macOS 六组停靠、自由拖动、入口重排已对齐。集成树独立运行 bun test --timeout 15000 src/features/plugins/native-ui/native-docking.test.tsx:10 项、137 项断言通过(5.54s,最慢 218ms);真实布局与面板组件组合覆盖三侧、焦点、工作区/包隔离、长树、窄窗口和组合输入,原生宿主、catalog 和 UI 状态动作为替身。该组件证据不能代替真实 MainLayout 产品、完整包、双窗口及 WebView2 浅深色/焦点/输入法验收;Windows 维持 partial/pending,ui:3 准入继续关闭。 Windows R14:native-main-layout.test.tsx 在独立进程中实跑 9 项、161 项断言通过(Bun 2.29s,最慢 329ms);真实 MainLayout、目录刷新/撤回、UI slices/actions、生产活动入口、布局槽、NativeToolWindow、renderer、typed port 与函数会话组合覆盖三侧同时显示、终端挂载身份、逆序登记/迟到释放、同槽替换、包/工作区隔离、焦点与重建后的字段/树状态。原生调用/审批/校验、终端内容、会话持久化仓库及无关功能仍为替身;重建保留当前进程内存,未验证重启后的布局恢复。宿主标签切换重建只模拟窗口身份,不能视为真实双窗证据。完整包、WebView2、真实 IME、主题及双窗口验收仍 pending,维持 partial/pending,ui:3 继续关闭。 Windows R15:工具栏消费 action.icon.host,15 个批准 ID 显式映射到公共图标及逐字节保留的 IDEA 浅深资源;未声明沿用默认。公共 InfoTip 支持延迟悬停、点击/原生按钮键盘激活固定与切换、Escape/外点击、辅助说明与焦点、禁用字段/动作说明及卸载清理。独立 lane 聚焦组件计时 24 项/313 断言通过;主窗最终前端 10 文件 74 项、原生 762 项及 4 项既有跳过、Node 54 项及 1 项平台跳过通过,完整 TypeScript、格式/lint、共享契约、稳定性、Windows 边界、资源只读及正式 x64 Release 通过。显式 Core stdio 四操作用例 1480ms 通过(90 秒预算)。Happy DOM Enter/Space 显式补发浏览器默认 click,组件回归不能代替真实 WebView2、中文 IME、浅深主题、完整包或双窗口;partial/pending、ui:3 关闭。 Windows R16:公共 SearchField 增加 search.clear 真按钮,清空只更新草稿并恢复输入焦点,Enter 才提交空字符串;隐藏重开恢复空草稿,disabled/readOnly 不可清空,InfoTip 独立可用。主窗前端14文件88项、Windows宿主762项通过,宿主4项既有夹具跳过;完整TypeScript、格式/lint、共享契约、稳定性、Windows边界、原始资源只读门禁和正式x64 Release通过,330个PR范围内Windows/Core/i18n源码构建前后SHA-256一致;Happy DOM 显式补按钮默认 click,WebView2、真实 IME、主题、完整包及双窗仍 pending,partial/pending 和 ui:3 关闭保持。 Windows R17:renderer 将 primary 映射公共 Button accent、normal 映射 default;原始 native-controls 夹具覆盖禁用/InfoTip、草稿重开和完整单次动作。已开放 DeclarativePluginList 消费公共 SearchField 的真实 WebView2/CDP Enter/Space 默认清空、同一输入焦点、过滤和选中恢复通过,未补 click,专项无插件IPC;证据来自独立x64宿主基线ae89d437、exe SHA256 fa50a76fccc57df0d76c9871a88c695e1806a913d89fe8004ccb8ecb09d20cc2。不是物理键盘/IME、ExtensionsSidebar、native ui3或完整签名包验收,保持partial/pending和ui:3关闭。 Windows R18:viewport为当前选择处理Shift+F10/ContextMenu,滚动挂载后经公共BaseUI anchor打开行菜单,菜单键盘事件不触发树导航;虚拟行移出关闭、busy/disabled不误执行、单次typed Inspect目标准确、卸载清理。外点击与动作新焦点owner受到保护,Escape回树。原shared夹具的Happy DOM回归不是浏览器默认键盘/真实IME/视觉或完整包证据,ui:3继续关闭、partial/pending。 Windows R19:Windows 原生表单动作组按完整组宽度选择横排或纵排:测量现有公共 Button/InfoTip 行的自然宽度和既有间距,空间不足时整组纵排,不能用 flex-wrap 留下多列折行。只切换同一个容器的方向,保留 DOM、输入草稿、焦点和单次完整 values 提交;字段栅格与旧 width/span 兼容语义不变。组件与真实 Edge 几何回归证明宽窄切换,不代表真实 MainLayout/WebView2、物理键盘、IME 或完整包验收。仓库入口 `scripts/test-windows-native-plugin-ui-browser.mjs` 使用真实 Edge、生产样式和公共组件,Tauri 调用使用官方 mock;导航前捕获全部 window.error/pageerror。执行与清理共用总期限,工作进程先归属 Windows Job(关闭即终止全部成员的系统进程组)再释放,退出后核实零成员才删除 profile/cache;早退、超时或缺失清理证明均失败。JSON/HTML/JUnit 与独立 CI 步骤维护六阶段组件回归;它不启动产品,不等于 WebView2 或 ui:3 完整包验收,ui:3 继续关闭。 集成复跑发现并修复 ResizeObserver 回调修改方向引发的反馈错误,改为首轮同步测量及后续单帧合并、卸载取消;相同六阶段真实 Edge 几何、草稿、焦点和完整 values 均通过,导航前 window.error 为零。 R20 集成:Edge 六阶段通过(约34秒),页面/启动/超时/worker早退或挂起均失败且Job归零;supervisor早退缺proof保留scratch并失败。浏览器watchdog仅用于真实帧可能暂停的有界DOM观察,卸载前完成并清理,不是一般异步测试计时器豁免。 Windows R21:Windows NativeTree 的浏览器回归复用维护后的入口与生产样式、真实公共 ContextMenu/Dialog 和受控 typed 函数端口,键盘只由 Playwright 的浏览器默认路径驱动。菜单弹出时,公共 modal 会把背景移出可访问树,因此测试只用局部 DOM locator 观察背景几何;不得为测试关闭 modal。外部第一次指针点击由遮罩消费并关闭菜单,下一次正常点击才编辑输入,不能声称一次点击就把焦点交给输入。真实包、WebView2、物理键盘、IME 与 ui:3 仍待验收。 真实 Edge 七阶段已通过(38.5秒),window.error/pageerror均零、Job归零且profile/cache清除;菜单打开后的500ms上游点击保护期由Playwright受控时钟推进,不用真实sleep,也不声称立即外点击穿透。 Windows R22:R22 的表单栅格回归固定浏览器 viewport,只改变原 native-controls 夹具容器,在 900→639→640→900px 检查 12 列/span 的几何、同一控件 DOM、当前草稿与编辑焦点、禁用说明、公共 readOnly 原语和完整 values 单次提交。宽度按真实 grid 校准,包含滚动条占宽,不关闭 overflow 或硬写滚动条像素;segmented 的禁用沿 fieldset 检查实际按钮;typed 端口无 selection 为 undefined,不冒充下游 JSON null。主集成首次 Edge 发现测试断言错误,修复测试后七阶段真实 Edge 通过(36.5 秒),window.error/pageerror 为零,Job 归零后 profile/cache 已清理;未改生产组件。lane 原先的 TLS 安装失败和未执行项保持原记录,主集成通过只补受控组件证据,不升级 ui:3、WebView2、物理键盘、IME 或完整包验收。 Windows R23:控件输入回归继续使用原 native-controls 完整夹具和公共控件。真实浏览器负责指针、键盘默认行为与焦点:文本/多行编辑只改草稿,搜索 Enter 和声明的 onCommit 才提交;select、checkbox、segmented、stepper 按各自契约提交完整当前 values,最后显式按钮只提交一次。定位 checkbox 的公开角色及 stepper 的实际编辑节点,不能把 BaseUI 隐藏校验 input 当成第二个用户输入;下拉键盘操作先在局部期限内证明列表和选项焦点已就绪,不用 sleep 猜测上游安排。禁用组仍可查看说明,但指针和 Tab 不得进入编辑节点。失败证据单独保留,成功也必须核实页面错误为零、Job 零成员和 profile/cache 清理;这些是受控 Edge 组件证据,不升级 ui:3、WebView2、真实 IME 或完整包验收。\r Windows R24:工作台组合回归将真实 MainLayout、活动入口、项目页签、公共控件、三侧槽及 typed 函数会话放在同一 Edge 页面中验证;原生 I/O、审批、终端内容和包/窗口外部事件仍使用与 Bun 组件测试共享的受控边界。替换包时暂停旧 prepare,再释放后必须证明旧操作没有 confirm 或读取结果,新 owner 的动作确实完成这两步;单独看到 cancel 不足以证明旧动作未执行,因为成功动作收尾也会取消原会话。工作区切换使用真实项目入口;同页重建受控窗口身份仅验证隔离规则,不冒充真实双窗口、产品 WebView2 或 ui:3 准入。\r", + "lastReviewed": "2026-10-29" } diff --git a/shared/platform-feature-matrix/features/declarative-plugin-contract.json b/shared/platform-feature-matrix/features/declarative-plugin-contract.json index a23d456e39..8166f1d714 100644 --- a/shared/platform-feature-matrix/features/declarative-plugin-contract.json +++ b/shared/platform-feature-matrix/features/declarative-plugin-contract.json @@ -7,6 +7,8 @@ "evidence": [ "shared/contracts/plugins/v1", "rust/lithe-core/src/plugins/declarative.rs", + "macos/Tests/LitheTests/PluginSignatureVectorTests.swift", + "shared/contracts/plugins/v1/fixtures/signature-vectors.json", "macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift", "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift", "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginStartupLoader.swift", @@ -31,6 +33,9 @@ "evidence": [ "windows/tauri/src-tauri/src/plugin_host/mod.rs", "windows/tauri/src-tauri/src/plugin_host/store.rs", + "shared/contracts/plugins/v1/fixtures/signature-vectors.json", + "windows/tauri/src-tauri/src/plugin_host/install_tests.rs", + "windows/tauri/src-tauri/src/plugin_host/signature.rs", "windows/tauri/src-tauri/src/plugin_host/scan.rs", "windows/tauri/src-tauri/src/plugin_host/launch.rs", "windows/tauri/src-tauri/src/plugin_host/tests.rs", @@ -48,12 +53,45 @@ "windows/tauri/src-tauri/src/plugin_host/lifecycle.rs", "windows/tauri/src/features/plugins/declarative-plugin-ui.ts", "windows/tauri/src/features/plugins/declarative-plugin-ui.test.ts", - "windows/tauri/src/platform/lsp-core-adapter.test.ts" + "windows/tauri/src/platform/lsp-core-adapter.test.ts", + "windows/tauri/src/features/editor/lsp/declarative-language-support.ts", + "windows/tauri/src/features/editor/lsp/declarative-language-support.test.ts", + "windows/tauri/src/features/editor/hooks/use-lsp-integration-declarative.test.tsx", + "scripts/test-windows-declarative-plugin-host.ps1", + "scripts/plugin-host-acceptance/acceptance_driver.py", + "windows/tauri/src/features/editor/lsp/lsp-client-attachment-lifecycle.test.ts", + "windows/tauri/src/features/editor/hooks/use-lsp-attachment-lifecycle.test.tsx", + "windows/tauri/src/features/editor/stores/buffer-lsp-close.test.ts", + "windows/tauri/src/features/editor/hooks/use-lsp-queued-owner.test.tsx", + "scripts/plugin-host-acceptance/lsp_lifecycle_acceptance.py", + "scripts/plugin-host-acceptance/lsp_lifecycle_acceptance_test.py", + "windows/tauri/src-tauri/src/plugin_host/run_plans/tests.rs", + "windows/tauri/src/features/plugins/declarative-plugin-run.test.ts", + "windows/tauri/src/ui/dialog-confirm-cancellation.test.ts", + "windows/tauri/src/features/run-actions/hooks/use-run-action-discovery.integration.test.tsx", + "windows/tauri/src/features/run-actions/utils/merge-run-actions.ts", + "scripts/plugin-host-acceptance/settings_functions_acceptance.py", + "scripts/plugin-host-acceptance/settings_functions_acceptance_test.py", + "scripts/plugin-host-acceptance/settings_functions_fixture.py", + "scripts/plugin-host-acceptance/vcs_move_diagnostic.py", + "scripts/plugin-host-acceptance/web_bridge_fallback_fixture.py", + "scripts/plugin-host-acceptance/web_bridge_fallback_acceptance.py", + "scripts/plugin-host-acceptance/web_bridge_fallback_acceptance_test.py", + "scripts/plugin-host-acceptance/text_window_fixture.py", + "scripts/plugin-host-acceptance/text_window_acceptance.py", + "scripts/plugin-host-acceptance/text_window_acceptance_test.py", + "scripts/windows-build-preflight.ps1", + "scripts/plugin-host-acceptance/build_preflight_test.py", + "scripts/prepare-jdtls.ps1", + "scripts/windows-tree-sitter-proxy.cjs", + "scripts/windows-tree-sitter-proxy.test.cjs", + "scripts/windows-frontend-install.test.ts" ], "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Extensions", - "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:[1,2], serverProcess:1, settings:[1,2], ui:[1,2]},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。M2 已补磁盘安装链(快照、installReview 审批、staging、回滚)与最小管理界面;M3 已补声明式设置(settings/languageServers 2 能力声明,全局+项目级存储,immediate 效果经 workspaceConfiguration 下推)、运行/测试计划命令与契约版 PHP bundled 包(随安装器分发,validate/launch/runPlan 端到端测试覆盖)。M4 已补 Web 工具窗口(ui 能力声明提升为 [1,2]):lithe-plugin:// scheme 只服务启用包内普通文件(拒 reparse point、路径逃逸、超 16MiB),响应带 CSP(default-src none、拒 eval/联网/frame)与 nosniff/no-store;window.lithe 桥 v0 提供 context、ui.getState/setState(宿主持有,64KiB 上限)、workspace.openFile 与主题事件,build.*/functions.* 在 Windows 构建系统能力接入前返回 plugin.web.unknownMethod。尚未实现:Worker 扩展清单向契约迁移(Worker 版 PHP 提供 composer scripts 动态运行动作,契约无等价能力,完整迁移推迟;见 Note)、构建系统插件能力与多仓库源市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。 后续安全修复已补插件身份传递、运行中和待启动会话排空、跨窗口包变更确认、完整启动前检查及 ui 1 文本/命令入口;目前 WebView 仍为独立窗口,停靠位置、图标、动作状态及布局持久化未完成。", - "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:宿主 v0 已读取该契约(语言与语言服务器范围,按 hostCapabilities 门控),接入验收必须通过同一组共享 fixture,Windows 宿主测试已对 invalid-manifests.json 全量断言;Worker 扩展清单的迁移未完成,宿主 UI 仅完成文本和命令入口,不能用共享校验通过代替界面验收。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 仍缺停靠、图标和动作状态等对齐,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。 Windows 安全回归:plugin_host::launch 的版本探测必须在父进程退出、后代继承管道时仍有界结束并清理进程树;web 回归验证真实文档来源与工作区文件目标。 Windows 语言资源整合:声明式插件的运行分组、管理页与安装审查使用共享生成词条;中英文回归验证安装名称/版本、程序数量和启停标签,并确认用户值中的占位符保持字面文本。 Windows 宿主修复回归:在 Core startServer 尚未返回时禁用插件,晚到会话必须先停止再拒绝;即时设置携带 pluginId,Core 接受并下推对应会话。缺失 requiredPackagePaths/runtimeRequirements 或 validationArguments 非零退出时拒绝启动。Hello Lithe 文本窗口、菜单、命令面板和工具栏可见,禁用后全部撤销;停靠、图标、动作状态及持久布局仍待接入,保持 partial。" + "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:[1,2], serverProcess:1, settings:[1,2], ui:[1,2]},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。M2 已补磁盘安装链(快照、installReview 审批、staging、回滚)与最小管理界面;M3 已补声明式设置(settings/languageServers 2 能力声明,全局+项目级存储,immediate 效果经 workspaceConfiguration 下推)、运行/测试计划命令与契约版 PHP bundled 包(随安装器分发,validate/launch/runPlan 端到端测试覆盖)。M4 已补 Web 工具窗口(ui 能力声明提升为 [1,2]):lithe-plugin:// scheme 只服务启用包内普通文件(拒 reparse point、路径逃逸、超 16MiB),响应带 CSP(default-src none、拒 eval/联网/frame)与 nosniff/no-store;window.lithe 桥 v0 提供 context、ui.getState/setState(宿主持有,64KiB 上限)、workspace.openFile 与主题事件,build.*/functions.* 在 Windows 构建系统能力接入前返回 plugin.web.unknownMethod。尚未实现:Worker 扩展清单向契约迁移(Worker 版 PHP 提供 composer scripts 动态运行动作,契约无等价能力,完整迁移推迟;见 Note)、构建系统插件能力与多仓库源市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。 后续安全修复已补插件身份传递、运行中和待启动会话排空、跨窗口包变更确认、完整启动前检查及 ui 1 文本/命令入口;目前 WebView 仍为独立窗口,停靠位置、图标、动作状态及布局持久化未完成。 M1 信任链已接入:windows/tauri/src-tauri/src/plugin_host/signature.rs 与 macOS PluginPackageSignature 字节兼容(canonical payload、UTF-8 base64url token、按原始路径排序、每文件 SHA-256、contentDigest),使用成熟的 ed25519-dalek 校验固定官方公钥(lithe-official-plugins-v2);官方 ID 信任策略由宿主固定(php/java-support=sameTeamAsHost、go-support=publisherPackage),覆盖包自声明,sameTeamAsHost 仅信任 bundled 资源;签名文档存在即必须验证。安装记录升为 schema 2:origin、marketplace/disk provenance(全包内容摘要与批准程序)、当前/上一版本证据成一致事务,版本激活与记录提交同事务、提交失败恢复旧字节与旧记录;schema 1 旧记录不凭 enabled 伪造信任,扫描直接隔离并要求重新安装;bundled 身份仅由可信资源发现器产生,installed 记录声明 bundled(含 previousOrigin)一律隔离,sameTeamAsHost 在扫描、启动与回滚均校验真实资源根包含。prepare 冻结整包摘要与审查程序,confirm 与 staging 对同一审核身份校验,prepare 后替换/新增/删除文件拒绝;磁盘渠道接受已验签 publisherPackage 包(保留真实 disk provenance),sameTeam 磁盘包拒绝。安装确认、staging 发布前、启动扫描、运行/测试计划入口与回滚均校验完整文件集合与摘要(community 与 marketplace origin 都复检已批准摘要);ZIP 大小写冲突条目、reparse point、路径逃逸与签名文档损坏均被拒绝。双端共享公开向量 shared/contracts/plugins/v1/fixtures/signature-vectors.json(RFC 8032 测试密钥锚点,非 ASCII 文件名覆盖),Rust 端经 shared_fixture_verifies_end_to_end 断言;Swift 端 PluginSignatureVectorTests 消费同一文件,macOS 实机运行待 CI。生产私钥不进入源码与测试。 安装中断恢复使用完整版本名备份并复核 durable record 批准摘要,校验插件身份和单段版本路径;真实替换产生的备份清理失败、下一次记录提交失败与越界记录均有 Windows 回归。 R4 将文档附件绑定 buffer、工作区与插件注册代次;关闭/重开、迟到启动/关闭/排队文本同步不能借用新 owner,插件替换/更新/回滚后已打开编辑器重新附加。扩展可选 LSP 生命周期原生验收,按事件字节偏移、文档 URI、包 marker 和持有句柄身份确认新 didOpen,而非仅数存活 PID。共享契约和宿主能力版本不变,测试与验收范围分别记录,整体继续 partial/pending。 Windows R6 接入内部声明式运行和工作区/文件测试计划,Core 保持发现/argv 的唯一来源;仅使用上游 case ID,suite 明确 unavailable。保存与同步后复验完整包、工作区代次、源码及选择,原生审批后交给既有 Run owner;迟到启动的停止失败保留 owner 以便重试,审批随失效关闭,Worker 优先级不变。API 1/2 兼容路径保留。executionProviders 能力尚未开放,未完成真实插件产品验收,不视为完整交付。 Windows R7 将工作区测试发现从活动编辑器解耦:按已接受/启用插件的执行声明与 Core discoverTests 生成 workspace 入口,当前文件保留精确 file scope;Worker 继续先取,已有语言/构建测试仅按显式同范围去重,重复激活只进入一次既有运行 owner。Core 尚无可信 case/suite 来源,产品入口明确 unavailable;executionProviders 与现有产品门控保持关闭,真实启用包与端到端验收仍 pending。 Windows R25 构建依赖:根 lock 的 1050 个 npm tarball URL 迁至官方源,机器比较精确版本、完整依赖图与 SRI 不变。两个 tree-sitter-cli 的完整上游 lifecycle 经 Node 原生 proxyEnv Agent 成功接收、解压并以 PE x64 --version 验证;此证据不替代 frozen 全量安装、当前提交的正式 Release identity 或 ui:1 九阶段产品验收,完整 M10 仍 partial/pending,ui:3、Provider 与 functions/build gates 保持关闭。 安装工具固定 Bun 1.3.14,复用其官方 TLS 握手及 isolated-linker 下载/完整性失败挂起修复,不改变产品依赖版本。 本轮 Bun 1.3.14 frozen 全量安装及正式安装 worker 已成功,两个实际 CLI 身份均验证;正式入口的短路径源码清单先解析模块真实路径,再相对仓库根生成键,离线回归不代替九阶段产品验收。", + "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:宿主 v0 已读取该契约(语言与语言服务器范围,按 hostCapabilities 门控),接入验收必须通过同一组共享 fixture,Windows 宿主测试已对 invalid-manifests.json 全量断言;Worker 扩展清单的迁移未完成,宿主 UI 仅完成文本和命令入口,不能用共享校验通过代替界面验收。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 仍缺停靠、图标和动作状态等对齐,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。 Windows 安全回归:plugin_host::launch 的版本探测必须在父进程退出、后代继承管道时仍有界结束并清理进程树;web 回归验证真实文档来源与工作区文件目标。 Windows 语言资源整合:声明式插件的运行分组、管理页与安装审查使用共享生成词条;中英文回归验证安装名称/版本、程序数量和启停标签,并确认用户值中的占位符保持字面文本。 Windows 宿主修复回归:在 Core startServer 尚未返回时禁用插件,晚到会话必须先停止再拒绝;即时设置携带 pluginId,Core 接受并下推对应会话。缺失 requiredPackagePaths/runtimeRequirements 或 validationArguments 非零退出时拒绝启动。Hello Lithe 文本窗口、菜单、命令面板和工具栏可见,禁用后全部撤销;停靠、图标、动作状态及持久布局仍待接入,保持 partial。;Windows 集成检查发现并修复声明式专有后缀无法进入编辑器 LSP 激活门的问题:未被 Java/Worker 认领的本地文件异步查询原生语言归属,实际启动继续复用现有解析与信任检查;停用清除缓存、拒绝迟到答案,并在文档仍打开时释放附件。门控与 hook 回归覆盖启动、禁用及虚拟/无归属路径。验收驱动以保留进程句柄及创建时间/可执行文件身份清理,拒绝按历史 PID 清理无关进程;完整 Release/WebView2 流程仍待本次构建重验,整体保持 pending。 Windows R4:逐文件计时验证 pending lookup/attach/detach、相同路径新 buffer、工作区切换、旧同步队列和插件注册变化。Release/WebView2 启用 --lsp-lifecycle,覆盖有界初始化门控期间关闭重开、重复关闭重开及已打开文档在停用再启用、同版本替换、更新和回滚后的新 didOpen;同时验证旧进程树清理和 2382 项发行资源哈希不变。尚未执行的原生步骤不能记为 verified。 R7:运行 use-run-action-discovery.integration.test.tsx、merge-run-actions.test.ts 与 declarative-plugin-run.test.ts 的计时回归,覆盖无编辑器工作区发现、真实 hook/菜单调用、切换失效、Worker 优先、精确范围、重复动作和既有停止失败重试;完整 Windows 产品和 Rust/Release 由集成窗口统一验证。 Windows R16:原锁 Bun 1.3.12 安装成功,进程级代理路由修复未改锁定依赖或 TLS 校验。独立 x64 宿主(源码基线 01bef2c3)实跑 controlled-settings 八项全部通过:安装审批及完整程序声明、显式保存/空值/重置/CAS、真实双窗、项目重载凭据、全局重载、升级/停用/卸载、观察器排空和退出后资源哈希。两窗原生 incarnation 为 1/2,观测调用 162/91,pending/dropped/unsettled 均为零;观察 fetch/callback Map,不改 invoke。该受控 unsigned 包不是正式发布包,函数七项、postMessage 回退、arm64、IME/键盘与主题仍 pending,整体验证状态不升级。 Windows R17:独立x64宿主基线ae89d437的受控设置9/9完成(原8项加公共搜索真实WebView2/CDP Enter/Space默认清空、焦点和过滤/选中恢复),观察器pending/dropped/unsettled均0、正常退出后资源及包哈希不变、owned进程清单为空。宿主exe SHA256 fa50a76fccc57df0d76c9871a88c695e1806a913d89fe8004ccb8ecb09d20cc2;不能代替最终集成提交整套产品、物理键盘/IME、arm64或完整发布签名包。函数7项、postMessage回退和现有门控仍待完成。 Windows R17:新增显式固定输入SVN move诊断入口,case/suite硬deadline、先Job归属后释放、零成员证明后才恢复读取,异常总清理且清理无证明时隔离保留;拒绝真实路径/junction输入输出重叠,最终哈希/读取失败写入失败JUnit。三真实case复现精确叶子成功、E155033但target schedule added、目录误嵌套;全部safetyPass=false,不计生产安全通过。源码stdio+官方SVN+wrapper gate不等于Windows正式Provider包或生产宿主验收。临时runtime/repo/WC/profiles/证据不可跨worktree复用,保持move/vcsProviders关闭与partial/pending。 Windows R18:独立x64 Release基线272f2e3f、exe SHA256 c488037ab0cf18a4e2e9bda10aaf60e2302a37a91498af0096e95bedfba0ba36完成受控ui:2状态桥9阶段真实验收:完整unsigned安装审查/零程序、真实私有WebView2、CDP精确一次IPC POST故障后原Promise经真实postMessage成功、后续getState读回nonce且POST仍1次。固定warning1次、真实callback Map三次均0、冻结transport描述符/函数摘要不变;正常关闭卸载退出,发行镜像/包/ZIP不变、profile和owned进程清空。helper10项只证明匹配和清理,不能代替原生传输;该产品证据不是最终集成树运行,也不证明物理网络、ui1/functions/ui3、arm64或正式签名完整包;保持partial/pending与全部gate。 Windows R19:ui:1 字面文本安全验收使用一次调用生成的完整 unsigned API3 包,仅声明 ui.contribute;计数服务先绑定 loopback 端口并做正向控制,再生成 ZIP 和绑定当前源码身份的 plan。正式入口必须冻结构建前后源码清单并绑定可执行文件、配置和提交,不能给旧二进制补造当前 identity。打开私有页面前订阅浏览器请求,用原始 Document 和桥脚本两条请求防止空迹通过;比较文本、DOM、CSP、冻结 transport、回调排空及包和发行镜像字节。浏览器请求观察只能证明其可见的网络请求,不能把未观察到请求或空回调 Map 写成精确零 postMessage IPC。辅助测试仅验证失败边界。R20 正式入口在资源校验前缺 native Bun PATH,依赖恢复经历 TLS 失败;完成依赖/helper/frontend 后,短命进程检查竞态又中断构建,最后耗尽 90 分钟总预算,未产出验收 EXE。九个真实运行时步骤均未执行。R21 已补构建前 native bun.exe 定位与固定版本检查,安装局部预算由验收入口经 build 传到 installer(每次 1~1200 秒、默认 300 秒);短命进程仅在原进程退出或句柄已触发等正向证明下忽略,仍在运行却无法检查时失败。Tauri 结束后通过同一个独占文件句柄比较 Cargo.toml:原字节不写,只有精确 CRLF→LF 结果才恢复;语义编辑、占用或删除均保留现状并拒绝 identity,构建原错和检查错同时保留。13 项准备回归及进程 owner 回归通过;这只修复工具,未运行本轮 Cargo/Release/产品,仍须下一轮一次正式构建和九阶段验收。真实安装、文本页、隐藏重开和退出仍 pending,不能据此升级 M10。 Windows R22:R22 唯一正式入口因 PATH 前置 MSYS 而选中 GNU tar,在 JDTLS 解压阶段中断,未进入 Cargo、未产出 EXE,九个真实步骤全部未执行。准备脚本现从平台 SystemDirectory 定位原生 tar,不依赖 PATH;缺失在缓存/输出变更前失败,解压非零保留原生错误并停止后续复制。16 项离线准备回归通过,涵盖绝对盘符/空格路径的小 tar.gz/VSIX、假 PATH tar、坏归档及提前缺失;这仍只证明工具边界,必须另做一次正式产品验收。 R23 正式入口已通过原生 tar 的 JDTLS 准备,随后依赖安装先耗尽 1200 秒局部预算,既有的一次 clean retry 又在 tree-sitter-cli 的 GitHub 二进制下载遇到 TLS ECONNRESET。尚未进入 Cargo,没有 EXE 或构建 identity,ui:1 九阶段仍为 0/9,不能用准备回归或旧 Release 补足。该下载由 npm 包的 install.js 另行发起,npm tgz 的 SRI 不证明 GitHub 二进制;修复应先核实当前 Node 与子进程实际采用的代理/重定向路径,保持 TLS 校验,再在同一任务内完成完整安装、冻结源码、构建和一次正式验收。 R24 的真实 Node 24.15 子进程已验证两个 tree-sitter-cli 版本的 CONNECT、受系统信任的 TLS 以及 302/200 响应头,但探针随即停止读取;响应头不能证明完整下载、gzip 或 lifecycle 安装成功。本轮安装还观察到 tree-sitter-c-sharp 与 @xterm/xterm 的 npm tarball SRI 完整性错误,随后主动取消;不能把两条错误分别记为首次及内部重试。降低并发后的另一次安装达到 1200 秒期限,日志不足以定位停在何处。因此未进入正式构建,ui:1 九阶段继续 0/9 pending。下一步先对比 lock 固定镜像与 npm 官方源的完整字节/SRI,再验证两个真实 install.js 的完整下载;不得只凭 HTTP 200、重设相同代理或扩大期限宣称已修复。v0.23.2 官方资产摘要仍为 null,未建立二进制复用路径,45 项离线/helper 通过只证明辅助边界。 针对失败包的四次完整传输补证表明,官方源的 tree-sitter-c-sharp@0.23.5(3,685,327 字节)和 @xterm/xterm@6.0.0(1,418,562 字节)均匹配当前 lock SRI,包内精确版本及依赖声明也一致,无需因此修改版本或这两个节点的依赖。镜像 C# 包在本地 60 秒截止前未完成,不能把截止触发的 reset 当作远端主动重置;xterm 两源完整字节相同。xterm 官方 metadata 的 TLS 请求失败,只有 C# 另获官方 metadata integrity/shasum;这些证据支持验证官方来源方案,尚未证明 Bun 能覆盖 lock 显式 URL、全量安装或 GitHub lifecycle 下载成功。 R25:冻结提交 2193e60a4cc3df484c4ee129dc437b992b330548 完成官方 npm 来源 frozen 全量安装、原始 tree-sitter-cli 两版本 lifecycle、当前源码 Windows x64 Release 和受控 unsigned API3/ui:1 字面文本九阶段 9/9。正式入口四次,第三次产品通过但报告聚合失败,第四次 ResumeBuild 修复归档后退出 0;源码库存/EXE/配置身份、DOM/CSP/请求及正控、回调排空、资源哈希和 owned 清理均已核实。原 lane 的 zsh 门禁未运行,仅等价扫描及真实资源不变性通过;集成树另跑原脚本,不覆盖原记录。集成审查另补非法代理在环境修改前脱敏拒绝、安装夹具 preload 复制及保留原进程句柄的回归验证;正式产品证据仍绑定 2193e60,不把审查修复后的新 SHA 冒充已重建。ui:3/vcsProviders/functions/buildProviders/buildSystems、签名完整包、arm64 及完整 M10 继续 pending,矩阵保持 partial/pending。 Windows 当前集成基线 04f411889d4264343fb2e548619543f5136a25d5 已完成正式 x64 Release,5011 项源码库存与 EXE/配置身份冻结;ui:1 九阶段、默认/LSP 生命周期 19 项和受控设置九阶段通过,八个安装目录检查点与对应 ZIP 字节一致。设置完整入口按设计非零退出,真实 functions/完整 M10 仍被前提阻断。ui:2 原入口因漏算五个 third_party 输入启动前失败;归档适配入口仅换用既有完整库存计算器,重新计算并与原 marker 精确匹配后,原生回退链九阶段通过。源码清单现统一包含 tracked 与未忽略新输入,web helper 13 项、text 兼容 12 项及真实根目录两 lane 5011 项一致;这些后续 helper 修复不冒充新 SHA 的运行时证据。发行镜像/包/ZIP 字节未变,owned 进程及隔离 profile 已清理;受控 LSP 不证明真实 JDT/Provider 完整包,ui:3/functions/vcsProviders/buildProviders/buildSystems、正式签名完整包、arm64 与完整 M10 继续 pending。", + "lastReviewed": "2026-10-29" } diff --git a/shared/platform-feature-matrix/features/declarative-plugin-settings.json b/shared/platform-feature-matrix/features/declarative-plugin-settings.json index 5dde4cbd23..5a5dcd7d52 100644 --- a/shared/platform-feature-matrix/features/declarative-plugin-settings.json +++ b/shared/platform-feature-matrix/features/declarative-plugin-settings.json @@ -27,11 +27,49 @@ "shared/contracts/plugins/v1/fixtures/settings-v2.plugin.json", "windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts", "windows/tauri/src/platform/lsp-core-adapter.ts", - "windows/tauri/src/platform/lsp-core-adapter.test.ts" + "windows/tauri/src/platform/lsp-core-adapter.test.ts", + "windows/tauri/src-tauri/src/plugin_host/settings/snapshot.rs", + "windows/tauri/src-tauri/src/plugin_host/settings/snapshot/tests.rs", + "windows/tauri/src/features/plugins/declarative-settings-session.ts", + "windows/tauri/src/features/plugins/declarative-settings-session.test.ts", + "windows/tauri/src/features/plugins/declarative-settings-pages.ts", + "windows/tauri/src/features/plugins/declarative-settings-pages.test.ts", + "windows/tauri/src/features/plugins/use-declarative-settings-pages.ts", + "windows/tauri/src/features/settings/components/settings-dialog.tsx", + "windows/tauri/src/features/settings/components/settings-hierarchy.test.tsx", + "windows/tauri/src/features/plugins/details/plugin-details-view.tsx", + "windows/tauri/src/features/plugins/details/plugin-details-view.test.tsx", + "windows/tauri/src/features/plugins/declarative-plugin-reload.ts", + "windows/tauri/src/features/plugins/declarative-plugin-reload.test.ts", + "windows/tauri/src/features/plugins/declarative-plugin-reload-notices.tsx", + "windows/tauri/src/features/plugins/plugin-lifecycle-listener.test.ts", + "windows/tauri/src/features/plugins/declarative-plugin-ui.ts", + "windows/tauri/src/features/plugins/declarative-plugin-ui.test.ts", + "windows/tauri/src-tauri/src/plugin_host/lifecycle.rs", + "windows/tauri/src/features/plugins/declarative-plugin-reload-notices.test.tsx", + "windows/tauri/src-tauri/src/plugin_host/functions/mod.rs", + "windows/tauri/src-tauri/src/plugin_host/functions/runtime.rs", + "windows/tauri/src-tauri/src/plugin_host/functions/tests.rs", + "windows/tauri/src-tauri/src/plugin_host/functions/runtime/tests.rs", + "windows/tauri/src/features/plugins/plugin-function-session.ts", + "windows/tauri/src/features/plugins/plugin-function-session.test.ts", + "windows/tauri/src/features/plugins/plugin-function-action.tsx", + "windows/tauri/src/features/plugins/declarative-plugin-settings-editor.test.tsx", + "windows/tauri/src/features/plugins/declarative-plugin-reload-project.ts", + "windows/tauri/src/features/plugins/declarative-plugin-reload-project.test.ts", + "windows/tauri/src-tauri/src/plugin_host/settings/reload.rs", + "windows/tauri/src-tauri/src/plugin_host/build_registry/settings_reload.rs", + "windows/tauri/src-tauri/src/plugin_host/build_tests.rs", + "scripts/plugin-host-acceptance/settings_functions_fixture.py", + "scripts/plugin-host-acceptance/settings_functions_acceptance.py", + "scripts/plugin-host-acceptance/settings_functions_acceptance_test.py", + "scripts/plugin-host-acceptance/settings_functions_inputs.json", + "scripts/test-windows-declarative-plugin-host.ps1" ], "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Plugin Platform", - "verification": "macOS:打开设置 › 插件,选中 Java Support,切到“设置”页签,确认显示“最大内存”(默认 1024 MB)和“语言服务器日志级别”(默认全部消息)。把最大内存改成 3072:输入 3072 后点“重启语言服务器”,用 ps 查看 JDT LS 的 java 进程参数,确认 -Xmx3072m 且只出现一次;输入 100 时出现范围提示且没有保存,失焦后回到上一个合法值。把日志级别改成“仅错误”并重启,确认进程参数里是 -Dlog.level=SEVERE。点“恢复默认值”后重启,确认回到 -Xmx1024m 和 -Dlog.level=ALL。退出并重新打开 Lithe,确认修改过的值仍在。卸载并恢复 Java Support,值仍在。Windows:契约设置页与双层存储已接入,须完成下述实机验证。 Windows:启动契约语言服务器,修改 immediate 设置,确认 lsp.startServer 和即时更新都带独立 pluginId,已启动会话收到 workspace/didChangeConfiguration;禁用后旧会话不能继续接收。设置双层持久化和界面验收仍 pending。" + "verification": "macOS:打开设置 › 插件,选中 Java Support,切到“设置”页签,确认显示“最大内存”(默认 1024 MB)和“语言服务器日志级别”(默认全部消息)。把最大内存改成 3072:输入 3072 后点“重启语言服务器”,用 ps 查看 JDT LS 的 java 进程参数,确认 -Xmx3072m 且只出现一次;输入 100 时出现范围提示且没有保存,失焦后回到上一个合法值。把日志级别改成“仅错误”并重启,确认进程参数里是 -Dlog.level=SEVERE。点“恢复默认值”后重启,确认回到 -Xmx1024m 和 -Dlog.level=ALL。退出并重新打开 Lithe,确认修改过的值仍在。卸载并恢复 Java Support,值仍在。Windows:契约设置页与双层存储已接入,须完成下述实机验证。 Windows:启动契约语言服务器,修改 immediate 设置,确认 lsp.startServer 和即时更新都带独立 pluginId,已启动会话收到 workspace/didChangeConfiguration;禁用后旧会话不能继续接收。设置双层持久化和界面验收仍 pending。 Windows R8:按 page.languageID/buildSystemID 注册到语言/构建分组,section 分组,无 page 的设置保留插件详情;打开只读,逐项草稿、保存/取消、选择全局或工作区、空值与继承重置、外部字节快照冲突、写回重读已接入。依赖独立的前端状态测试通过,完整组件、原生存储及实机验收仍 pending。实机须验证真实 app-data/工作区路径、全局值被项目覆盖的标记、外部修改拒绝覆盖、禁用/升级/切工作区后的旧结果失效、重开一致、发行目录不变。restart/nextRun 等待下次启动/运行;immediate 仅匹配的活动会话收到配置才显示即时应用。Windows R9:global reload 在 CAS 保存后单独确认,通过 M1 插件级跨窗口屏障撤销贡献、取消操作并等待运行进程、语言服务及原生租约退出;不写持久 enabled 状态。随后重新扫描已接受包、验签并读取有效设置,匹配保存快照及 packageIdentity 后恢复贡献,语言服务按需重新启动。停止失败保留包与旧租约,公共设置区保留可重试入口;其他插件不受影响。包字节变化保持撤销;纯设置冲突仅在发布已重新验证的目录后恢复并要求读取新基线。主窗口前端与 Tauri 宿主逐项计时回归、完整 TypeScript 检查和 Windows x64 Release 构建通过;新增混合生效方式连续保存会刷新旧 reload receipt,包变化或 busy 不覆盖原凭据。完整安装包、实际多窗口 reload、失败恢复及发行目录哈希实机验证仍 pending。project reload 的原生局部执行闸门由 R12 接入,验证边界见后文。settingActions 仍缺少 M6 Provider 函数链,明确不可用。 Windows R10:设置 action 从已接受、已启用且实时验签的归一化声明确认函数,以真实窗口、原生工作区代次、完整包与设置身份绑定确认票据;原生固定空对象参数并使用隔离 Core owned 会话,协商 litheFunctions、结果/进度、取消和单 owner 清理重试。共享编辑器或构建会话不被接管;无唯一独立 Provider 的 action 明确 unavailable;网页函数桥、通用 backend、host services 和完整真实包/实机验收仍 pending。能力门控保持关闭。主窗口已完成设置动作组件/会话、R9 reload/CAS 回归、完整 TypeScript 检查、Tauri 逐项原生测试及 Windows x64 Release;新增最后清理等待者退出后的迟到启动/销毁自动回收,以及结果凭据保留回归。真实安装包、网页桥、共享语言 Provider 私有函数及 host services 仍 pending。 Windows R12:Core 继续拥有校验、分层、空值/继承与生效语义。项目 reload 绑定真实原生窗口实例、项目登记、保存设置与包身份;先屏蔽当前窗口/工作区/插件的新调用,再等待其旧构建、普通/网页/原生面板函数及 VCS owner 精确清理,只重读目标构建模型,其他插件和窗口保持有效。持久私有设置修订拒绝晚 prepare/approval;清理失败保留原 owner 重试,Provider 初始化清理会真正重试。纯 VCS 项目不要求存在构建模型。原生预检不可取消,进入模型阶段后才开放取消。全局 reload 保留跨窗口插件屏障。 主窗口 R12:36 个前端文件 325 项、Tauri 宿主最终 751 项通过及 4 项夹具跳过、Node 85 项通过及 1 项平台跳过;完整 TypeScript、共享契约、测试稳定性、Windows 边界、bundle 只读静态门禁和 Windows x64 Release 已通过。首轮两个新增夹具失败已修复并保留失败记录,最终受影响原生 208 项重验通过,并补最终 VCS 分类回归 65 项。真实安装 Provider/系统客户端与进程树、完整包、双窗口及 WebView2 人工验收、macOS 和 Windows arm64 仍 pending;受控测试与 x64 编译不能替代这些证据。 Windows R13:新增 settings-only API3 受控包与双窗口设置/CAS/项目 reload/生命周期/发行资源哈希验收驱动;保存 reload 的步骤走注册语言设置页,校验包内 text 声明与当前源码/构建身份。辅助测试及 preflight 可运行,但预检对能力关闭的函数和缺少真实签名完整包明确非零退出;真实产品执行项仍 pending,不能把驱动存在或受控设置包当成完整函数与安装包验收。Windows 保持 partial/pending,ui:3/vcsProviders 准入关闭。 Windows R14:验收驱动加强 SkipBuild 的当前提交/目标/config/executable hash 身份匹配,保留已绑定配置,资源快照安排在观察器和原生进程退出之后。独立聊天依赖安装遇到 FTK1011、ENOENT 和下载超时,未生成当前提交隔离宿主,未启动产品,八项设置及函数/arm64/人工视觉仍 pending;辅助自测通过不证明运行验收。 Windows R15:短名称独立工作树完成固定 Bun/lock 与资源复制核对,依赖安装仍在 tree-sitter-cli GitHub 资产下载遇到 TLS ECONNRESET,局部代理修复后非零退出。没有当前提交的隔离宿主,未启动产品;设置八项、函数七项、arm64 与人工视觉均 pending。未更改源码、正式包 requires、门控或审批边界。 Windows R16:原锁 Bun 1.3.12 安装成功,进程级代理路由修复未改锁定依赖或 TLS 校验。独立 x64 宿主(源码基线 01bef2c3)实跑 controlled-settings 八项全部通过:安装审批及完整程序声明、显式保存/空值/重置/CAS、真实双窗、项目重载凭据、全局重载、升级/停用/卸载、观察器排空和退出后资源哈希。两窗原生 incarnation 为 1/2,观测调用 162/91,pending/dropped/unsettled 均为零;观察 fetch/callback Map,不改 invoke。该受控 unsigned 包不是正式发布包,函数七项、postMessage 回退、arm64、IME/键盘与主题仍 pending,整体验证状态不升级。 Windows R17:独立x64宿主基线ae89d437的受控设置9/9完成(原8项加公共搜索真实WebView2/CDP Enter/Space默认清空、焦点和过滤/选中恢复),观察器pending/dropped/unsettled均0、正常退出后资源及包哈希不变、owned进程清单为空。宿主exe SHA256 fa50a76fccc57df0d76c9871a88c695e1806a913d89fe8004ccb8ecb09d20cc2;不能代替最终集成提交整套产品、物理键盘/IME、arm64或完整发布签名包。函数7项、postMessage回退和现有门控仍待完成。", + "lastReviewed": "2026-10-28" } diff --git a/shared/platform-feature-matrix/features/plugin-detail-tabs.json b/shared/platform-feature-matrix/features/plugin-detail-tabs.json index 407ae0be01..b077e3aff0 100644 --- a/shared/platform-feature-matrix/features/plugin-detail-tabs.json +++ b/shared/platform-feature-matrix/features/plugin-detail-tabs.json @@ -18,11 +18,22 @@ }, "windows": { "evidence": [ - "windows/tauri/src/features/settings" + "windows/tauri/src-tauri/src/plugin_host/details.rs", + "windows/tauri/src-tauri/src/plugin_host/details_tests.rs", + "windows/tauri/src/features/plugins/details/plugin-details-view.tsx", + "windows/tauri/src/features/plugins/details/plugin-details.service.test.ts", + "windows/tauri/src/features/plugins/details/plugin-details-view.test.tsx", + "windows/tauri/src/features/plugins/declarative-plugins-section.tsx", + "windows/tauri/src/features/plugins/declarative-plugin-list-model.ts", + "windows/tauri/src/features/plugins/declarative-plugin-list.tsx", + "windows/tauri/src/features/plugins/declarative-plugin-list-model.test.ts", + "windows/tauri/src/features/plugins/declarative-plugins-section.test.tsx", + "scripts/plugin-host-acceptance/acceptance_driver.py", + "scripts/plugin-host-acceptance/acceptance_driver_test.py" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "partial", + "verificationStatus": "pending" }, "owner": "Plugin Platform", - "verification": "macOS:打开设置 › 插件 › 已安装,选中 Java Support。头部显示名称、官方徽标、版本和状态胶囊,右侧有启用/禁用、卸载按钮。依次切换五个页签:概览显示状态、提供的能力、来源和设置数量,“在设置中配置”可跳到 LSP 和运行配置页;功能页签列出 Java 语言、语言服务器(使用插件自带的 java 运行)以及由 Lithe 提供的运行、测试、调试;设置页签显示最大内存和日志级别,修改后出现“重启语言服务器”,“恢复默认值”可一键还原;权限与程序页签列出 languageServer.launch 与 JDT LS 启动命令,内容与安装确认弹窗一致;版本与来源显示当前版本、插件 ID 和签名。安装一个社区插件,确认社区提示固定在页签上方,任何页签都能看到。在左侧点“语言服务”“社区 / 磁盘”“已禁用”等过滤芯片,确认列表随之变化并按“已启用 / 已禁用”分组;在搜索框输入 /,确认出现过滤选项,选中后搜索框清空并切换过滤。更新过的插件在版本与来源页签显示上一版本和“回滚…”,确认后需要重启 Lithe。布局:在浅色和深色下缩放设置窗口、往返拖动插件列表分隔条,切换 Java/Cargo 与各页签,确认标题、页签和正文不越过分隔线;窄设置表单可横向滚动至末尾,输入控件保持同一实例,扩大后恢复填满正文。运行 PluginDetailLayoutTests 检查这些边界。Windows:扩展管理尚未拆分页签。" + "verification": "Windows 逐文件运行 plugin-details service/view、declarative-plugin-list-model 和 declarative-plugins-section 回归及 typecheck;覆盖名称/ID 搜索、可信摘要筛选、状态分组、键盘选择、筛选保留选中项与页签、完整权限/程序、安装审批、启停/回滚/卸载后刷新,以及过期结果和卸载清理。验收驱动已适配分组列表与独立详情按钮,显式 --dom-browser msedge 的实际 DOM 回归通过;旧构建的13通过/4跳过不作为本轮新 UI 实机证据。新构建仍需实机检查五页签、中英文、窄窗口/主题、插件动作和多窗口;整体保持 partial/pending。macOS 按既有五页签和管理动作测试及实际界面验证。" } diff --git a/shared/platform-feature-matrix/features/plugin-functions.json b/shared/platform-feature-matrix/features/plugin-functions.json index 77f40eb887..6677598991 100644 --- a/shared/platform-feature-matrix/features/plugin-functions.json +++ b/shared/platform-feature-matrix/features/plugin-functions.json @@ -23,10 +23,38 @@ "evidence": [ "rust/lithe-core/src/lsp/interface/plugin_function.rs", "rust/lithe-core/src/lsp/interface/engine.rs", - "shared/contracts/plugins/v1/README.md" + "shared/contracts/plugins/v1/README.md", + "windows/tauri/src-tauri/src/plugin_host/functions/mod.rs", + "windows/tauri/src-tauri/src/plugin_host/functions/runtime.rs", + "windows/tauri/src-tauri/src/plugin_host/functions/tests.rs", + "windows/tauri/src-tauri/src/plugin_host/functions/runtime/tests.rs", + "windows/tauri/src/features/plugins/plugin-function-session.ts", + "windows/tauri/src/features/plugins/plugin-function-session.test.ts", + "windows/tauri/src/features/plugins/plugin-function-action.tsx", + "windows/tauri/src-tauri/src/plugin_host/functions/web.rs", + "windows/tauri/src/features/plugins/plugin-web-function.ts", + "windows/tauri/src/features/plugins/plugin-web-function-approval.tsx", + "windows/tauri/src/features/plugins/plugin-web-open-file.ts", + "windows/tauri/src-tauri/src/plugin_host/web.rs", + "windows/tauri/src-tauri/src/plugin_host/web_build.rs", + "windows/tauri/src/features/plugins/plugin-web-function.test.ts", + "windows/tauri/src/features/plugins/plugin-functions.ts", + "windows/tauri/src/features/plugins/plugin-function-action.test.tsx", + "windows/tauri/src/features/plugins/plugin-function-cleanup-notices.tsx", + "windows/tauri/src/features/plugins/plugin-function-cleanup-notices.test.tsx", + "windows/tauri/src-tauri/src/plugin_host/functions/native.rs", + "windows/tauri/src-tauri/src/plugin_host/functions/native/tests.rs", + "windows/tauri/src/features/plugins/native-ui/native-functions.ts", + "windows/tauri/src/features/plugins/native-ui/native-functions.test.ts", + "scripts/plugin-host-acceptance/settings_functions_fixture.py", + "scripts/plugin-host-acceptance/settings_functions_acceptance.py", + "scripts/plugin-host-acceptance/settings_functions_acceptance_test.py", + "scripts/plugin-host-acceptance/settings_functions_inputs.json", + "scripts/test-windows-declarative-plugin-host.ps1" ] }, "owner": "Extensions", - "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib plugin_function 及 --lib lsp::interface::engine,核对共享 fixture、初始化发现事件、动态增删、归属拒绝、结果与插件错误码、超时取消撤回。插件会话成功完成 initialize 函数协议协商后发布初始 functionsChanged(含空注册表),无 pluginId 的宿主会话不协商、不保留或广播函数。每次后端调用使用唯一且不透明的 operationID,进度转换回宿主 operationId,所有终止路径清除关联;复用宿主 ID 后,旧进度与撤回不得影响新函数或构建请求。后端请求 lithe/function/invoke 或 lithe/build/* 时拒绝递归派发,lithe/host/* 在 v1 全部拒绝。平台验收:宿主接入后,从同一插件的界面发现并调用函数,验证另一插件无法调用、终止后的旧进度不污染新调用,关闭项目和停用插件后不残留进程;分别在 macOS 和 Windows 完成原生验收后才标记 verified。 macOS 宿主:scripts/test-macos.sh --filter \"PluginWebBridgeTests|MacBuildProviderSessionsTests\",覆盖插件网页只能访问本插件实例并经同意、结果/进度/pluginErrorCode 到达页面、取消与关闭页面取消函数、构建 Provider 以插件会话启动并带 pluginId、Core 拒绝码原样保留、停用插件停止 Provider。 macOS 原生验收:debug 应用安装带网页窗口的 sample-build(PR #1365)与另一个带构建系统的插件,在示例工作区的页面上依次调用 functions.list、functions.invoke(带 call 观察 functions.progress)、functions.cancel,核对结果与 pluginErrorCode;从另一插件页面访问该实例应得到 plugin.web.forbidden;调用进行中关闭项目、停用插件后用 pgrep 确认 Provider 与构建子进程不残留。 官方 SDK(在 lithe-plugin-docs 仓库中运行):node --test sdk/node/test/*.test.mjs;mvn -B -f sdk/java/pom.xml verify(CI 以 -Djvm 指向 Java 8 运行测试);node scripts/sdk-e2e.mjs 把 sdk/e2e 复制进契约副本锁定的主仓库 commit 并运行 plugin_sdk_node、plugin_sdk_java,两个示例插件经真实 Core 会话完成构建模型/依赖、函数结果/进度/失败/取消、重启恢复状态,且插件包文件清单不变,Java 示例按插件打包形态(plugin.json + shade 后的 JAR)运行;CI 每周再以 --ref HEAD 对主仓库最新 Core 运行一次。", - "notes": "macOS 已接入宿主入口:插件网页通过 functions.list / functions.invoke / functions.cancel 调用自己构建 Provider 进程的函数,共享语言服务器不提供函数;2026-10-20 已在 macOS debug 应用中用 Node SDK 的示例插件 sample-build(PR #1365,当时在主仓库 sdk/node/examples,加一个只用于验收的网页窗口)完成原生验收:functions.list/invoke 的结果、functions.progress、动态注册的 snapshot.clear、pluginErrorCode(sample.buildFailed)、取消后的 requestCancelled 都到达页面,取消会结束构建子进程;另一插件的页面访问该实例得到 plugin.web.forbidden;调用进行中关闭项目或停用插件后 Provider 及其子进程均不残留。Windows 尚无插件后端宿主;不能以共享测试通过替代平台功能验收。函数不新增清单字段,不改变 pluginAPIVersion 或 requires;litheBuild v1 与旧后端继续独立工作,lithe/host/* 暂不开放,协议不提供操作系统级沙盒。 官方 Node SDK(lithe-plugin-docs 的 sdk/node)只实现契约、不新增线上字段,进程树清理在 POSIX 由端到端测试覆盖,Windows 的 taskkill 路径尚未在 Windows 上验证。 官方 Java SDK(lithe-plugin-docs 的 sdk/java)同样只实现契约;Java 9+ 取消时结束整棵进程树,Java 8 只结束直接子进程,其余由宿主的后端进程标记兜底;目前只经共享 Core 端到端验证,macOS 原生验收随 Maven 插件的 Java 后端一起完成。运行时启动计划的 java 版本探测改用 -version,Java 8 后端不再被误判为不可用。" + "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib plugin_function 及 --lib lsp::interface::engine,核对共享 fixture、初始化发现事件、动态增删、归属拒绝、结果与插件错误码、超时取消撤回。插件会话成功完成 initialize 函数协议协商后发布初始 functionsChanged(含空注册表),无 pluginId 的宿主会话不协商、不保留或广播函数。每次后端调用使用唯一且不透明的 operationID,进度转换回宿主 operationId,所有终止路径清除关联;复用宿主 ID 后,旧进度与撤回不得影响新函数或构建请求。后端请求 lithe/function/invoke 或 lithe/build/* 时拒绝递归派发,lithe/host/* 在 v1 全部拒绝。平台验收:宿主接入后,从同一插件的界面发现并调用函数,验证另一插件无法调用、终止后的旧进度不污染新调用,关闭项目和停用插件后不残留进程;分别在 macOS 和 Windows 完成原生验收后才标记 verified。 macOS 宿主:scripts/test-macos.sh --filter \"PluginWebBridgeTests|MacBuildProviderSessionsTests\",覆盖插件网页只能访问本插件实例并经同意、结果/进度/pluginErrorCode 到达页面、取消与关闭页面取消函数、构建 Provider 以插件会话启动并带 pluginId、Core 拒绝码原样保留、停用插件停止 Provider。 macOS 原生验收:debug 应用安装带网页窗口的 sample-build(PR #1365)与另一个带构建系统的插件,在示例工作区的页面上依次调用 functions.list、functions.invoke(带 call 观察 functions.progress)、functions.cancel,核对结果与 pluginErrorCode;从另一插件页面访问该实例应得到 plugin.web.forbidden;调用进行中关闭项目、停用插件后用 pgrep 确认 Provider 与构建子进程不残留。 官方 SDK(在 lithe-plugin-docs 仓库中运行):node --test sdk/node/test/*.test.mjs;mvn -B -f sdk/java/pom.xml verify(CI 以 -Djvm 指向 Java 8 运行测试);node scripts/sdk-e2e.mjs 把 sdk/e2e 复制进契约副本锁定的主仓库 commit 并运行 plugin_sdk_node、plugin_sdk_java,两个示例插件经真实 Core 会话完成构建模型/依赖、函数结果/进度/失败/取消、重启恢复状态,且插件包文件清单不变,Java 示例按插件打包形态(plugin.json + shade 后的 JAR)运行;CI 每周再以 --ref HEAD 对主仓库最新 Core 运行一次。 Windows:运行 Frontend 逐项计时 harness 的 plugin-function-session、plugin-function-action、plugin-web-function、plugin-lifecycle-listener,以及原生 plugin_host::functions 的真实 Core/受控运行时回归。覆盖审批前取消、页面和宿主归属、函数动态撤回、正式 {functions}/{value} 返回、进度和原始 pluginErrorCode、窗口资源 incarnation、4096 完成凭据随原窗口退休、迟到启动/销毁、清理失败保留与同 owner 重试。原生审批/启动/最终结果边界复验完整包,轮询不重复哈希整包。完整 TypeScript 和 Windows x64 Release 已由主窗口 R11 统一集成通过;真实插件包、多窗口 WebView2、共享语言 Provider 和 host services 验收仍 pending,不能用内部测试代替。 原生面板:Frontend 逐项计时 native-functions 与 native-panel 测试覆盖三个共享 fixture、完整公共控件到 typed port、审批/取消/失败/旧结果;原生 plugin_host::functions::native 覆盖 Core 参数/结果预算、句柄替换与唯一实例选择,原生编译由主窗口统一执行。 R12 增加原生面板 typed 函数入口及项目 reload 的精确 scope drain,普通调用与 Web pending approval 保留私有设置修订;真实 Core Scripted runtime 覆盖清理失败、原 lease 保留和仅目标 owner 重试。 主窗口 R12:36 个前端文件 325 项、Tauri 宿主最终 751 项通过及 4 项夹具跳过、Node 85 项通过及 1 项平台跳过;完整 TypeScript、共享契约、测试稳定性、Windows 边界、bundle 只读静态门禁和 Windows x64 Release 已通过。首轮两个新增夹具失败已修复并保留失败记录,最终受影响原生 208 项重验通过,并补最终 VCS 分类回归 65 项。真实安装 Provider/系统客户端与进程树、完整包、双窗口及 WebView2 人工验收、macOS 和 Windows arm64 仍 pending;受控测试与 x64 编译不能替代这些证据。 Windows R13:新增 settings-only API3 受控包与双窗口设置/CAS/项目 reload/生命周期/发行资源哈希验收驱动;保存 reload 的步骤走注册语言设置页,校验包内 text 声明与当前源码/构建身份。辅助测试及 preflight 可运行,但预检对能力关闭的函数和缺少真实签名完整包明确非零退出;真实产品执行项仍 pending,不能把驱动存在或受控设置包当成完整函数与安装包验收。Windows 保持 partial/pending,ui:3/vcsProviders 准入关闭。 Windows R14:验收驱动加强 SkipBuild 的当前提交/目标/config/executable hash 身份匹配,保留已绑定配置,资源快照安排在观察器和原生进程退出之后。独立聊天依赖安装遇到 FTK1011、ENOENT 和下载超时,未生成当前提交隔离宿主,未启动产品,八项设置及函数/arm64/人工视觉仍 pending;辅助自测通过不证明运行验收。 Windows R15:短名称独立工作树完成固定 Bun/lock 与资源复制核对,依赖安装仍在 tree-sitter-cli GitHub 资产下载遇到 TLS ECONNRESET,局部代理修复后非零退出。没有当前提交的隔离宿主,未启动产品;设置八项、函数七项、arm64 与人工视觉均 pending。未更改源码、正式包 requires、门控或审批边界。 Windows R16:原锁 Bun 1.3.12 安装成功,进程级代理路由修复未改锁定依赖或 TLS 校验。独立 x64 宿主(源码基线 01bef2c3)实跑 controlled-settings 八项全部通过:安装审批及完整程序声明、显式保存/空值/重置/CAS、真实双窗、项目重载凭据、全局重载、升级/停用/卸载、观察器排空和退出后资源哈希。两窗原生 incarnation 为 1/2,观测调用 162/91,pending/dropped/unsettled 均为零;观察 fetch/callback Map,不改 invoke。该受控 unsigned 包不是正式发布包,函数七项、postMessage 回退、arm64、IME/键盘与主题仍 pending,整体验证状态不升级。 Windows R17:受控设置9项真实验收含新增本地搜索,未观测native function IPC,不能计入functions7;完整sample的buildProviders/buildSystems要求继续关闭,无新生产函数验收。", + "notes": "macOS 已接入宿主入口:插件网页通过 functions.list / functions.invoke / functions.cancel 调用自己构建 Provider 进程的函数,共享语言服务器不提供函数;2026-10-20 已在 macOS debug 应用中用 Node SDK 的示例插件 sample-build(PR #1365,当时在主仓库 sdk/node/examples,加一个只用于验收的网页窗口)完成原生验收:functions.list/invoke 的结果、functions.progress、动态注册的 snapshot.clear、pluginErrorCode(sample.buildFailed)、取消后的 requestCancelled 都到达页面,取消会结束构建子进程;另一插件的页面访问该实例得到 plugin.web.forbidden;调用进行中关闭项目或停用插件后 Provider 及其子进程均不残留。Windows 的设置 action 和网页 functions.list/invoke/cancel 已接入同插件独立 Provider 的 Core 会话。网页审批位于产品窗口,调用和进度绑定原插件页面;清理失败保留可见的同 owner 重试,不因关闭审批而丢失。共享语言服务器、通用 backend、host services 与真实包验收仍 pending。函数不新增清单字段,不改变 pluginAPIVersion 或 requires;litheBuild v1 与旧后端继续独立工作,lithe/host/* 暂不开放,协议不提供操作系统级沙盒。 官方 Node SDK(lithe-plugin-docs 的 sdk/node)只实现契约、不新增线上字段,进程树清理在 POSIX 由端到端测试覆盖,Windows 的 taskkill 路径尚未在 Windows 上验证。 官方 Java SDK(lithe-plugin-docs 的 sdk/java)同样只实现契约;Java 9+ 取消时结束整棵进程树,Java 8 只结束直接子进程,其余由宿主的后端进程标记兜底;目前只经共享 Core 端到端验证,macOS 原生验收随 Maven 插件的 Java 后端一起完成。运行时启动计划的 java 版本探测改用 -version,Java 8 后端不再被误判为不可用。 Windows 原生 schema 1 的 source/action 已接入同一 dedicated Provider 函数 owner;挂载句柄和唯一检测实例由原生登记,审批等待占用单面板名额,清理失败继续保留全局同 owner 重试。ui:3 门控未开放,原生编译与真实完整包验收 pending。", + "lastReviewed": "2026-10-28" } diff --git a/shared/platform-feature-matrix/features/plugin-marketplace.json b/shared/platform-feature-matrix/features/plugin-marketplace.json index c6ba96c3fc..a75980c9d1 100644 --- a/shared/platform-feature-matrix/features/plugin-marketplace.json +++ b/shared/platform-feature-matrix/features/plugin-marketplace.json @@ -46,15 +46,41 @@ "evidence": [ "windows/tauri/src-tauri/src/plugin_host/install.rs", "windows/tauri/src-tauri/src/plugin_host/install_tests.rs", + "windows/tauri/src-tauri/src/plugin_host/marketplace_sources.rs", + "windows/tauri/src-tauri/src/plugin_host/marketplace_sources_tests.rs", "windows/tauri/src/features/plugins/declarative-plugin-launch.ts", "windows/tauri/src/features/plugins/declarative-plugins-section.tsx", + "windows/tauri/src/features/plugins/declarative-marketplace-sources.ts", + "windows/tauri/src/features/plugins/declarative-marketplace-sources-section.tsx", + "windows/tauri/src/features/plugins/declarative-marketplace-sources.test.ts", + "windows/tauri/src/features/plugins/declarative-marketplace-sources.test.tsx", "rust/lithe-core/src/plugins/marketplace.rs", - "shared/contracts/plugins/v1/fixtures/marketplace" + "shared/contracts/plugins/v1/fixtures/marketplace", + "windows/tauri/src-tauri/src/plugin_host/marketplace_index.rs", + "windows/tauri/src-tauri/src/plugin_host/marketplace_download.rs", + "windows/tauri/src-tauri/src/plugin_host/marketplace.rs", + "windows/tauri/src-tauri/src/plugin_host/marketplace_coordinator.rs", + "windows/tauri/src-tauri/src/plugin_host/marketplace_tests.rs", + "windows/tauri/src/features/plugins/declarative-marketplace.ts", + "windows/tauri/src/features/plugins/declarative-marketplace.test.ts", + "windows/tauri/src/features/plugins/marketplace/marketplace-types.ts", + "windows/tauri/src/features/plugins/marketplace/marketplace-service.ts", + "windows/tauri/src/features/plugins/marketplace/marketplace-session.ts", + "windows/tauri/src/features/plugins/marketplace/marketplace-section.tsx", + "windows/tauri/src/features/plugins/marketplace/marketplace-types.test.ts", + "windows/tauri/src/features/plugins/marketplace/marketplace-session.test.ts", + "windows/tauri/src/features/plugins/marketplace/marketplace-section.test.tsx", + "windows/tauri/src/features/plugins/marketplace/marketplace-host-service.ts", + "windows/tauri/src/features/plugins/marketplace/marketplace-host-service.test.ts", + "scripts/plugin-host-acceptance/marketplace_acceptance.py", + "scripts/plugin-host-acceptance/marketplace_fixture.py", + "scripts/plugin-host-acceptance/marketplace_acceptance_test.py", + "scripts/test-windows-declarative-plugin-host.ps1" ], "implementationStatus": "partial", - "verificationStatus": "not-applicable" + "verificationStatus": "pending" }, "owner": "Extensions", - "notes": "macOS 已在隔离实例实际验证社区 ZIP/目录选择与确认、本地来源和摘要、完整程序 JSON 展开、同版本再次确认及取消不改原记录、安装字节、重启扫描与 bundle 哈希不变;磁盘信任及受影响安装流程已运行定向回归,后台审查通过工作线程独占 Store 与文件管理器保持界面可取消,取消后等待读取结束再清理快照。页面关闭与迟到结果清理由状态回归覆盖,完整市场源管理、双语和全部磁盘组合尚未逐项实机验收,因此整体 verificationStatus 保持 pending。最终 Maven/Gradle 新清单还依赖待合入的共享契约和宿主能力,本次 preview 基线的受控包验收不替代最终插件整体验收。Windows 未实现本次声明式磁盘安装链,partial 不表示可以运行 macOS 流程。", - "verification": "macOS 打开插件管理,确认有“插件市场 / 已安装”两个标签和齿轮菜单;默认有“Lithe 官方”源(不能删除)和预置的社区源 Lithe Store(默认开启、可停用和删除,删除后重启不会再出现),Lithe Store 中能看到 Lua、Go、PHP 三个社区语言插件并可安装,“Lithe 官方”源当前没有可下载插件(Go/PHP 已迁到 Lithe Store),刷新后索引缓存写在 Caches 而 app bundle 不变。安装、更新或卸载插件后,插件管理顶部出现“插件变更将在重启 Lithe 后生效”横幅和“重启 Lithe”按钮,市场列表与详情里的“重启后生效”也变成该按钮;点击后走正常退出确认,确认后应用退出并自动重新打开(不会多开),取消任一确认则不重启。插件详情显示作者、来源仓库、标签、版本、许可证、发布日期和支持平台;索引带 SVG 或 PNG `icon` 的插件(如 Lithe Store 的 Go、PHP、Lua、Maven、Gradle)在列表、详情和“已安装”里显示自己的图标;图标缺失、非法、超限或仍在加载时显示 IDEA 风格首字母头像(英文取两个字母、中文取一个字,颜色按插件 ID 固定),只有未选择插件的空状态显示默认插件图标;断网导致的失败在网络恢复后再次打开页面或刷新仓库即可恢复,刷新仓库后分支 URL 的新图标会替换旧图标,快速切换插件时详情头部不会出现上一个插件的图标;图标不写 bundle;索引里没有当前架构的包时显示“暂不支持 <平台> 平台”且不可安装,插件 API 版本不满足时显示不支持原因。在“管理插件仓库”中添加一个 GitHub 仓库地址,确认解析到仓库根目录的 lithe-plugin-index.json,可刷新、停用和删除;弹窗按设计稿呈现:仓库列在圆角卡片里,每行是名称(旁边有标签:“Lithe 官方”和预置的 Lithe Store 为“官方”,用户添加的仓库为“社区”)、等宽索引地址和状态行(“已于 刚刚 刷新”随时间变为“5 分钟前”,超过一周显示日期;有被忽略条目时追加“已忽略条目:N”,停用的源标注“已禁用”,刷新失败显示原因),右侧依次是启用开关、“刷新”和红色“移除”;“Lithe 官方”行只能停用,用灰色“默认”代替“移除”;底部左侧“全部刷新”,右侧“完成”。第三方源列出 dev.lithe.* 插件时该条目被忽略。安装社区插件时先校验 SHA-256(不匹配则不解压),超过下载上限的响应在下载中途被中止,解压后过大或文件过多的压缩包(含谎报大小的 ZIP)被拒绝且不留临时文件,再弹出确认框列出权限、要运行的程序和来源仓库,取消则不安装;已安装后改动插件目录内任意文件,重启后该插件拒绝加载;本地磁盘社区首次安装、更新、重装和同版本替换每次都必须明确确认,即使权限和程序未变;市场入口保持首次/缺少审批/出现 newPrograms 时确认的既有规则,无新程序的市场更新可沿用审批。确认框展示 Rust installReview 的全部权限与完整 canonical program 内容,包括参数、参数模板、验证探测、初始化选项、运行时与构建声明,已识别程序展开 Full Program Declaration 阅读完整 JSON,未知程序直接显示完整 JSON;同一插件出现在多个源时由用户选源,更新只走安装时记录的源。官方源只接受 Lithe 签名包。分别在英文和简体中文界面下检查列表、详情、安装确认框和仓库管理窗口,文字(含刷新时间格式)都跟随 Lithe 的语言设置,插件描述在有 localizedDescriptions 翻译时显示对应语言,否则显示默认英文描述;用辅助功能“按下”列表行只会选中,不会触发安装。索引里最新版本在本机不可用时,详情的版本、发布日期和平台显示实际会安装的版本,并单独注明最新版本不可用。磁盘验收:安装声明内置 Python 或其他已安装插件语言的社区包,重启拒绝冲突包后,原拥有者未设置、显式关闭或开启的偏好均保持原值;正常首次确认安装仍自动启用,更新保留选择。分别在市场下载和磁盘导入入口构造显式 ZIP 条目未超限但唯一隐式父目录超限的包,以及累计规范化路径 UTF-8 字节超限的包,确认在启动解压前拒绝并清理;显式目录与共享隐式父目录只计一次,恰好预算的合法包可解压。在隔离的用户配置与注入临时根下,从齿轮菜单分别选择一个合法社区 ZIP 与解包目录;准备后检查只生成本次 lithe-plugin-disk- 私有快照,ZIP 的 archiveSHA256 与快照 archive.zip 实测 SHA-256 相同,目录无归档摘要,界面显示本地文件名/内容摘要并明确未认证发布者或仓库。先取消,确认未安装且本次目录删除;再确认安装并重启,核对 installation.json 仅有 disk 来源、内容 digest 与完整 canonical approvedPrograms,没有伪造 marketplace 来源。分别导入程序未变的新版、相同版本原包及相同版本改过脚本的包,每次必须完整展示权限和程序并等待确认;取消不得改变已安装版本。确认框等待时更换原始 ZIP/目录,安装内容应仍等于准备快照;在受控测试中改动已准备快照后提交,应在 install staging 重验失败;安装后增加/删除/改动文件应在 scan 拒绝加载,改动上一版本后 rollback 应失败。正常升级再回滚时,当前/上一版本各自保留 origin、disk/marketplace 来源;同版本替换保留既有上一版本。刷新包含同 ID 的市场源,确认磁盘版本不出现可自动接管的更新/重装入口。以符号链接、Finder alias、特殊文件、多个 plugin.json、超过 64 层的目录、落在源目录内的临时根、中央目录/local header 路径类型冲突或重复条目的 ZIP、超限或损坏 ZIP、非法声明、冒用官方 ID/namespace 的包验证拒绝与清理;官方 publisher 签名、sameTeamAsHost 的 bundle 边界及仅 DEBUG 可用的 localDevelopment 包仍按原策略。导入和待确认期间关闭插件页,确认任务取消、已有快照立即清理、迟到的准备结果只清理而不会安装;成功、取消和失败后检查本次快照和 staging 无残留;在典型工作流前后比较发行 bundle 文件清单/哈希相同,关闭本次应用与子进程。静态及资源验证命令:node scripts/test-reuse-worktree-resources.mjs、./scripts/verify-agent-notes.sh、./scripts/verify-platform-feature-matrix.sh、./scripts/verify-runtime-bundle-immutability.sh;macOS 产品测试使用 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|PluginPackageStore|PluginManager',核对报告包含实际磁盘用例,未运行产品验收前保持 pending。Windows 已实现契约插件的磁盘导入链:ZIP/目录→私有快照(拒绝 reparse point、路径逃逸、超限文件与恶意 ZIP 条目)→plugins.marketplace.installReview 全量审查(权限+完整程序清单,界面明示未认证发布者)→确认时摘要比对拒绝快照调包→staging 复验后激活→卸载/回滚/启停。磁盘导入的取消清理、快照篡改拒绝与 staging 失败清理由 Rust 测试覆盖;多仓库源市场 UI 仍缺失(M3+),实机验收前 verificationStatus 保持 not-applicable。Worker 扩展模型的非语言市场安装命令(install_extension_from_url 等)现注册为显式占位错误,等待独立后端。;市场列表与详情之间的分隔条使用真实页面回归:中英文描述与深浅主题下缩放窗口、拖动后释放,再刷新索引,详情视口始终位于分隔条右侧;正文按视口宽度换行,窄于来源选择器和提示卡所需宽度时可横向滚动至末端,短详情保持顶部对齐。测试使用私有临时偏好域,关闭窗口后清理;仅原生组件证据,整体实机验收仍 pending。" + "notes": "Windows 已接通原生市场目录、索引刷新、下载准备、人工确认与取消,以及设置页市场浏览、搜索、按来源显示和安装后刷新。Core 决定平台/API 可用性与归档;原生安装记录决定更新来源,磁盘、内置、未知来源和跨源同 ID 条目不能被接管。所有市场安装与更新均显示真实来源、完整权限和程序声明并要求明确确认;官方徽标只来自固定官方源与签名校验的原生结论。窗口关闭、源变更和页面卸载回收操作,迟到准备与确认不能污染新会话。Windows 产品端到端验收尚待新构建运行,保持 partial/pending。 已准备隔离原生市场验收入口、真实 HTTPS fixture 与完整程序声明/来源固定/取消/重启/更新驱动,并接入启动器可选参数;驱动单元与 DOM 测试不等于真实安装通过。受控交易仍需已有可信 HTTPS 证书及可达主机,官方发布者签名验收需正式 Windows 包;不修改 TLS/签名信任或系统信任库。", + "verification": "Windows:逐文件运行 declarative-marketplace、marketplace-types/session/section/host-service 和源管理测试,覆盖来源固定更新、权限扩张、取消先于登记、坏摘要、签名、staging/记录回滚、切源与并行审批竞态;Rust WindowsRust 计时套件覆盖真实 Core 与 M1 事务。运行 typecheck、i18n、资源复用拒绝路由和矩阵门禁。原生复验需在隔离用户目录完成添加源→刷新→搜索→完整审批→拒绝/取消→安装→重启→同源更新→停用/删除,并确认临时文件、所有子进程和发行资源哈希。macOS 按其既有市场/磁盘安装、来源、摘要、审批与资源只读测试执行;没有目标平台实机证据不得提升 verified。" } diff --git a/shared/platform-feature-matrix/features/plugin-project-generators.json b/shared/platform-feature-matrix/features/plugin-project-generators.json index 92c1c744f7..f074e9da6a 100644 --- a/shared/platform-feature-matrix/features/plugin-project-generators.json +++ b/shared/platform-feature-matrix/features/plugin-project-generators.json @@ -20,11 +20,28 @@ }, "windows": { "evidence": [ - "shared/contracts/plugins/v1/README.md" + "windows/tauri/src/features/window/components/new-project-content.tsx", + "windows/tauri/src/features/project-generators/project-generator-wizard.tsx", + "windows/tauri/src/features/project-generators/generation-session.ts", + "windows/tauri/src/features/project-generators/generation-session.test.ts", + "windows/tauri/src/features/project-generators/generator-form-session.ts", + "windows/tauri/src/features/project-generators/generator-form-session.test.ts", + "windows/tauri/src/features/project-generators/generator-enum-field.tsx", + "windows/tauri/src/features/project-generators/project-generator-wizard.test.tsx", + "windows/tauri/src-tauri/src/plugin_host/project_generators/mod.rs", + "windows/tauri/src-tauri/src/plugin_host/project_generators/catalog.rs", + "windows/tauri/src-tauri/src/plugin_host/project_generators/transaction.rs", + "windows/tauri/src-tauri/src/plugin_host/project_generators/tests.rs", + "rust/lithe-core/src/project_generators/", + "shared/contracts/plugins/v1/fixtures/project-generator.plugin.json", + "windows/tauri/src-tauri/src/plugin_host/project_generators/archive.rs", + "rust/lithe-core/src/project_generators/archive.rs", + "scripts/worktree-resources.json", + "scripts/test-reuse-worktree-resources.mjs" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "partial", + "verificationStatus": "pending" }, "owner": "Plugin Platform", - "verification": "macOS:从欢迎页和项目切换器点“新建项目”,确认左栏有“新建项目”(Java、空项目)和“生成器”(Maven Archetype、Spring Boot)两组且可搜索。Java 选 Maven、默认 JDK 创建,确认生成 pom.xml(maven.compiler.release 等于所选 JDK 主版本)、.gitignore 和 src/main/java/org/example/Main.java,项目打开后 Main.java 已打开、Build 工具窗口识别 Maven 项目。勾选“创建 Git 仓库”时项目根目录有 .git。Spring Boot 选几个依赖创建,确认从 start.spring.io 下载并解压、mvnw 可执行;把服务器 URL 改成不可达地址时报错且目标目录下没有残留的 .*.lithe-creating-* 临时目录。Maven Archetype 在已安装 mvn 时生成 quickstart 项目,未安装时提示找不到 mvn。停用 Java Support 后,三个条目变灰、创建按钮不可用,只出现一条“启用 Java Support”,点击后条目恢复可用;未安装 Maven 插件时出现“在插件市场中查找”并跳到市场搜索 maven。目标目录已存在时拒绝创建且不改动原目录。Windows:宿主不声明 projectGenerators,需要它的插件在市场中隐藏。" + "verification": "macOS:从欢迎页和项目切换器点“新建项目”,确认左栏有“新建项目”(Java、空项目)和“生成器”(Maven Archetype、Spring Boot)两组且可搜索。Java 选 Maven、默认 JDK 创建,确认生成 pom.xml(maven.compiler.release 等于所选 JDK 主版本)、.gitignore 和 src/main/java/org/example/Main.java,项目打开后 Main.java 已打开、Build 工具窗口识别 Maven 项目。勾选“创建 Git 仓库”时项目根目录有 .git。Spring Boot 选几个依赖创建,确认从 start.spring.io 下载并解压、mvnw 可执行;把服务器 URL 改成不可达地址时报错且目标目录下没有残留的 .*.lithe-creating-* 临时目录。Maven Archetype 在已安装 mvn 时生成 quickstart 项目,未安装时提示找不到 mvn。停用 Java Support 后,三个条目变灰、创建按钮不可用,只出现一条“启用 Java Support”,点击后条目恢复可用;未安装 Maven 插件时出现“在插件市场中查找”并跳到市场搜索 maven。目标目录已存在时拒绝创建且不改动原目录。Windows R8:保持 projectGenerators capability 关闭,需要它的插件仍在市场中隐藏,生产目录不会绕过 capability 准入。欢迎页和项目菜单均进入共用泛型向导,目录与字段来自 Core;在隔离 fixture 下验证真实 Core files / command 计划、派生默认值、所选 JDK、同级 staging、产物 collect、Git 初始化、原子不覆盖发布及既有打开工程/语言导入路径。验证失败、取消、目标并发出现、穿越、ADS、reparse/hard link、插件撤销和窗口销毁清理。独立 Windows 事务 smoke 与前端生命周期测试不替代完整 Tauri Cargo/Release 与实机向导验证,后者仍待主会话执行。archive 在创建 staging 前明确 unavailable:受限下载与有界 Core 解压尚未贯通;插件需求启用/市场跳转、字段 segmented 表现仍待补齐。已确认进程结束但文件删除失败时保留原 Transaction、目录身份与 NativeLease;解除文件占用后再次取消会真实重试清理,成功才释放 barrier。同步 Core/Git runner 返回进程树清理不确定结果时尚无可重试句柄,保留 staging 与 NativeLease 并阻止插件替换,恢复需进程重启,不宣称已清理。 Windows R9:archive 已在同级 staging 内贯通受限 HTTPS 下载和既有 Core 解压,重定向共享 128 MiB 总下载预算与最长 120 秒下载期限;Core 使用原 operation ID 和剩余操作期限,在 32 KiB 块读写边界协作取消,写入前检查穿越、符号链接、Windows ADS/保留名、64 层路径深度、20,000 个显式/隐式节点和 16 MiB 累计路径字节,展开内容最多 512 MiB。下载 ZIP 以 create_new 写入并在同步 Core 返回后删除,再校验完整树并原子不覆盖发布;共享计划没有 strip 字段,保留归档目录结构。受控假 HTTP 与真实 Core 测试覆盖下载取消、HTTPS/凭据/重定向拒绝、重定向总量、解压取消、无效 ZIP/链接/穿越与目标竞争。主窗口已完成真实 Core 与 Tauri 宿主逐项计时回归、完整 TypeScript 检查和 Windows x64 Release 构建;覆盖下载后 staging 身份复验、清理失败原 owner 重试、超时首个错误类型及精确路径预算边界。projectGenerators capability 继续关闭,需求插件启用/市场跳转、segmented 字段、条件 Main.java 宿主分支及完整安装包向导/创建后构建实机验收仍 pending。 Windows R10:已实现停用但已接纳生成器的查看和显式启用、按 Core activeRequirements 提示需求插件启用或预填搜索的既有市场、共享 segmented 控件,以及完整包身份绑定的草稿和创建前需求快照复验。会话回归覆盖条件切换、启用成功/拒绝/失败、关闭与切选迟到结果和同版本包替换;主窗口已通过组件/会话计时、完整 TypeScript、宿主原生计时测试和 x64 Release;maven + sample=true 真实 Core/Windows 事务 Main.java 用例通过。审查修复无关 blocked/untrusted 包阻断目录、需求备选提示过度阻断及刷新跨切选的加载/旧条目问题。完整安装包向导和创建后构建实机验收仍 pending。projectGenerators capability 保持关闭,Windows 继续标记 partial/pending。" } diff --git a/shared/platform-feature-matrix/features/plugin-registered-settings-pages.json b/shared/platform-feature-matrix/features/plugin-registered-settings-pages.json index 4576d8d480..2f0dd2915e 100644 --- a/shared/platform-feature-matrix/features/plugin-registered-settings-pages.json +++ b/shared/platform-feature-matrix/features/plugin-registered-settings-pages.json @@ -22,11 +22,30 @@ }, "windows": { "evidence": [ - "shared/contracts/plugins/v1/README.md" + "shared/contracts/plugins/v1/README.md", + "windows/tauri/src-tauri/src/plugin_host/functions/mod.rs", + "windows/tauri/src-tauri/src/plugin_host/functions/runtime.rs", + "windows/tauri/src-tauri/src/plugin_host/functions/tests.rs", + "windows/tauri/src-tauri/src/plugin_host/functions/runtime/tests.rs", + "windows/tauri/src/features/plugins/plugin-function-session.ts", + "windows/tauri/src/features/plugins/plugin-function-session.test.ts", + "windows/tauri/src/features/plugins/plugin-function-action.tsx", + "windows/tauri/src/features/plugins/declarative-plugin-settings-editor.test.tsx", + "windows/tauri/src/features/plugins/declarative-plugin-reload-project.ts", + "windows/tauri/src/features/plugins/declarative-plugin-reload-project.test.ts", + "windows/tauri/src-tauri/src/plugin_host/settings/reload.rs", + "windows/tauri/src-tauri/src/plugin_host/build_registry/settings_reload.rs", + "windows/tauri/src-tauri/src/plugin_host/build_tests.rs", + "scripts/plugin-host-acceptance/settings_functions_fixture.py", + "scripts/plugin-host-acceptance/settings_functions_acceptance.py", + "scripts/plugin-host-acceptance/settings_functions_acceptance_test.py", + "scripts/plugin-host-acceptance/settings_functions_inputs.json", + "scripts/test-windows-declarative-plugin-host.ps1" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "partial", + "verificationStatus": "pending" }, "owner": "Plugin Platform", - "verification": "macOS:先使用 Swift 6.3.3 构建 Release,确认插件页选择回调不触发编译器崩溃;在 Release 应用中选择插件设置页,确认其侧栏分组自动展开。macOS:安装一个声明了 settings 2 页面(page/section/scope/effect/settingActions)的插件并启用,确认设置侧栏的对应分组下出现该语言或构建系统页,页内按插件和 section 分组显示设置与按钮;搜索设置标题能定位到该页。打开项目后把 scope 为 project 的设置切到“此项目”并修改,确认项目下生成 .lithe/plugin-settings.json 且显示“已在此项目覆盖”,恢复默认后文件被删除。修改 effect 为 immediate 的设置,确认语言服务器不重启而配置生效;修改 restart 设置时出现重启提示。点击带 confirm 的按钮先弹确认,再调用插件函数并给出结果通知。停用插件后页面消失。macOS 布局回归:运行 PluginSettingsLayoutTests,检查 480/640/900 点宽、深浅主题的路径控件与选择按钮不重叠且留在容器内,点击选择按钮写入所选路径;在插件详情的窄设置页确认“选择…”文字完整。Windows:宿主仍只声明 settings/languageServers 1,需要 v2 的插件在市场中隐藏。" + "verification": "macOS:先使用 Swift 6.3.3 构建 Release,确认插件页选择回调不触发编译器崩溃;在 Release 应用中选择插件设置页,确认其侧栏分组自动展开。macOS:安装一个声明了 settings 2 页面(page/section/scope/effect/settingActions)的插件并启用,确认设置侧栏的对应分组下出现该语言或构建系统页,页内按插件和 section 分组显示设置与按钮;搜索设置标题能定位到该页。打开项目后把 scope 为 project 的设置切到“此项目”并修改,确认项目下生成 .lithe/plugin-settings.json 且显示“已在此项目覆盖”,恢复默认后文件被删除。修改 effect 为 immediate 的设置,确认语言服务器不重启而配置生效;修改 restart 设置时出现重启提示。点击带 confirm 的按钮先弹确认,再调用插件函数并给出结果通知。停用插件后页面消失。macOS 布局回归:运行 PluginSettingsLayoutTests,检查 480/640/900 点宽、深浅主题的路径控件与选择按钮不重叠且留在容器内,点击选择按钮写入所选路径;在插件详情的窄设置页确认“选择…”文字完整。Windows 已声明 settings/languageServers 1/2;完整包的 buildSystems/buildProviders/ui 等其它 requires 仍按实际能力门控,不代表注册页或函数的完整包已通过验收。 Windows R10:设置 action 从已接受、已启用且实时验签的归一化声明确认函数,以真实窗口、原生工作区代次、完整包与设置身份绑定确认票据;原生固定空对象参数并使用隔离 Core owned 会话,协商 litheFunctions、结果/进度、取消和单 owner 清理重试。共享编辑器或构建会话不被接管;无唯一独立 Provider 的 action 明确 unavailable;网页函数桥、通用 backend、host services 和完整真实包/实机验收仍 pending。能力门控保持关闭。主窗口已完成设置动作组件/会话、R9 reload/CAS 回归、完整 TypeScript 检查、Tauri 逐项原生测试及 Windows x64 Release;新增最后清理等待者退出后的迟到启动/销毁自动回收,以及结果凭据保留回归。真实安装包、网页桥、共享语言 Provider 私有函数及 host services 仍 pending。 Windows R12:Core 继续拥有校验、分层、空值/继承与生效语义。项目 reload 绑定真实原生窗口实例、项目登记、保存设置与包身份;先屏蔽当前窗口/工作区/插件的新调用,再等待其旧构建、普通/网页/原生面板函数及 VCS owner 精确清理,只重读目标构建模型,其他插件和窗口保持有效。持久私有设置修订拒绝晚 prepare/approval;清理失败保留原 owner 重试,Provider 初始化清理会真正重试。纯 VCS 项目不要求存在构建模型。原生预检不可取消,进入模型阶段后才开放取消。全局 reload 保留跨窗口插件屏障。 主窗口 R12:36 个前端文件 325 项、Tauri 宿主最终 751 项通过及 4 项夹具跳过、Node 85 项通过及 1 项平台跳过;完整 TypeScript、共享契约、测试稳定性、Windows 边界、bundle 只读静态门禁和 Windows x64 Release 已通过。首轮两个新增夹具失败已修复并保留失败记录,最终受影响原生 208 项重验通过,并补最终 VCS 分类回归 65 项。真实安装 Provider/系统客户端与进程树、完整包、双窗口及 WebView2 人工验收、macOS 和 Windows arm64 仍 pending;受控测试与 x64 编译不能替代这些证据。 Windows R13:新增 settings-only API3 受控包与双窗口设置/CAS/项目 reload/生命周期/发行资源哈希验收驱动;保存 reload 的步骤走注册语言设置页,校验包内 text 声明与当前源码/构建身份。辅助测试及 preflight 可运行,但预检对能力关闭的函数和缺少真实签名完整包明确非零退出;真实产品执行项仍 pending,不能把驱动存在或受控设置包当成完整函数与安装包验收。Windows 保持 partial/pending,ui:3/vcsProviders 准入关闭。 Windows R14:验收驱动加强 SkipBuild 的当前提交/目标/config/executable hash 身份匹配,保留已绑定配置,资源快照安排在观察器和原生进程退出之后。独立聊天依赖安装遇到 FTK1011、ENOENT 和下载超时,未生成当前提交隔离宿主,未启动产品,八项设置及函数/arm64/人工视觉仍 pending;辅助自测通过不证明运行验收。 Windows R15:短名称独立工作树完成固定 Bun/lock 与资源复制核对,依赖安装仍在 tree-sitter-cli GitHub 资产下载遇到 TLS ECONNRESET,局部代理修复后非零退出。没有当前提交的隔离宿主,未启动产品;设置八项、函数七项、arm64 与人工视觉均 pending。未更改源码、正式包 requires、门控或审批边界。 Windows R16:原锁 Bun 1.3.12 安装成功,进程级代理路由修复未改锁定依赖或 TLS 校验。独立 x64 宿主(源码基线 01bef2c3)实跑 controlled-settings 八项全部通过:安装审批及完整程序声明、显式保存/空值/重置/CAS、真实双窗、项目重载凭据、全局重载、升级/停用/卸载、观察器排空和退出后资源哈希。两窗原生 incarnation 为 1/2,观测调用 162/91,pending/dropped/unsettled 均为零;观察 fetch/callback Map,不改 invoke。该受控 unsigned 包不是正式发布包,函数七项、postMessage 回退、arm64、IME/键盘与主题仍 pending,整体验证状态不升级。 Windows R17:独立x64宿主基线ae89d437的受控设置9/9完成(原8项加公共搜索真实WebView2/CDP Enter/Space默认清空、焦点和过滤/选中恢复),观察器pending/dropped/unsettled均0、正常退出后资源及包哈希不变、owned进程清单为空。宿主exe SHA256 fa50a76fccc57df0d76c9871a88c695e1806a913d89fe8004ccb8ecb09d20cc2;不能代替最终集成提交整套产品、物理键盘/IME、arm64或完整发布签名包。函数7项、postMessage回退和现有门控仍待完成。", + "lastReviewed": "2026-10-28" } diff --git a/shared/platform-feature-matrix/features/plugin-web-tool-windows.json b/shared/platform-feature-matrix/features/plugin-web-tool-windows.json index 959e2aec51..95ffe826ce 100644 --- a/shared/platform-feature-matrix/features/plugin-web-tool-windows.json +++ b/shared/platform-feature-matrix/features/plugin-web-tool-windows.json @@ -50,11 +50,37 @@ "windows/tauri/src-tauri/src/plugin_host/lifecycle.rs", "windows/tauri/src/features/plugins/plugin-web-open-file.ts", "windows/tauri/src/features/plugins/plugin-lifecycle-listener.test.ts", - "windows/tauri/src/features/workspace/services/workspace-lifecycle.test.ts" + "windows/tauri/src/features/workspace/services/workspace-lifecycle.test.ts", + "windows/tauri/src-tauri/src/plugin_host/web_build.rs", + "windows/tauri/src-tauri/src/plugin_host/web_build_tests.rs", + "windows/tauri/src/features/plugins/plugin-web-build.ts", + "windows/tauri/src/features/plugins/plugin-web-build.test.ts", + "windows/tauri/src-tauri/src/plugin_host/web_build_native.rs", + "windows/tauri/src-tauri/src/plugin_host/web_build_workflow.rs", + "windows/tauri/src/features/plugins/plugin-web-build-workflow.ts", + "windows/tauri/src/features/plugins/plugin-web-build-workflow.test.ts", + "windows/tauri/src/features/plugins/plugin-web-build-approval.tsx", + "windows/tauri/src/features/plugins/plugin-web-build-approval.test.tsx", + "windows/tauri/src/features/build-systems/build-systems-service.ts", + "scripts/plugin-host-acceptance/web_bridge_fallback_fixture.py", + "scripts/plugin-host-acceptance/web_bridge_fallback_acceptance.py", + "scripts/plugin-host-acceptance/web_bridge_fallback_acceptance_test.py", + "scripts/plugin-host-acceptance/text_window_fixture.py", + "scripts/plugin-host-acceptance/text_window_acceptance.py", + "scripts/plugin-host-acceptance/text_window_acceptance_test.py", + "scripts/test-windows-declarative-plugin-host.ps1", + "scripts/windows-build-preflight.ps1", + "scripts/plugin-host-acceptance/build_preflight_test.py", + "scripts/prepare-jdtls.ps1", + "scripts/windows-tree-sitter-proxy.cjs", + "scripts/windows-tree-sitter-proxy.test.cjs", + "scripts/windows-frontend-install.test.ts" ], "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Plugin Platform", - "verification": "自动回归:文件对话框见 PluginWebBridgeTests 的 fileDialogsNameThePluginAndReturnOnlyTheChosenPath 与 oneFileDialogAtATimeAndAClosedPageLearnsNoChoice;LITHE_TEST_WINDOW_MODE=isolated ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginWeb|PluginManagerTests|PluginDiskInstallationTests|BuildSystemServiceTests',检查包内脚本、CSP、主题事件、nonPersistent 数据存储和包文件字节不变;验证错误方法/参数、跨插件/工作区/符号链接逃逸、激活中工作区切换、等待期限、关闭后释放、真实 resetDedicatedWindowSession 在设置窗口仍强持项目模型且网页隐藏时同步关闭页面并清空登记表(TestGate 挂起检测和模块退出,待处理/退出中/退出后请求均返回 plugin.web.forbidden,桥接激活次数不增且退出后 Execution 未重新激活;新回归编译和运行待 CI 验证)、拒绝启动时不认领其他运行、任务与测试两入口遇 busy 均不取消现有运行或清空输出(含 TestGate 控制等待确认期间出现新运行)、同名 demo application/example 依完整 tags 唯一选中且最终参数来自模型、真实 Cargo 模型经桥接选中 example 后通过 Core targetTags 校验并生成 run --example demo、缺少区分信息或伪造/错误类型 tags 被拒绝、停用后旧视图无法重建且工作区关闭不撤销停用、安装和回滚校验失败保留旧页面、社区磁盘同版本替换及升级经真实审查和管理器入口在修改资源及安装记录前同步关闭双项目隐藏页面,拒绝已批准的进程许可并阻止后注册窗口及重新启用在重启前重建(PluginDiskInstallationTests/diskReplacementClosesEveryProjectPageBeforeMutation;篡改快照校验失败保留旧页面)、替换前关闭页面、模型任务类别及声明运行项完整转发、双项目页面与同意记录同步失效、后注册窗口继承阻断、包变更后旧管理器启用不能解锁、弱引用不保留关闭窗口。node scripts/test-reuse-worktree-resources.mjs 拒绝网页状态跨工作树复用。产品界面验收仍 pending。macOS:在开发版里用“从磁盘安装插件”安装 web-panel 夹具(plugin.json 旁放 ui/index.html 和 icons/panel.svg),重启后打开右侧 Web Panel 窗口,确认页面显示、根元素带 data-theme 和 --lithe-* 变量,切换浅色/深色后 ui.themeChanged 到达;页面里的内联脚本和 fetch 外网被 CSP 拦截。在页面控制台调用 lithe.request('ui.setState', {state:{a:1}}),隐藏再显示窗口后 ui.getState 仍返回该值;调用未知方法得到 plugin.web.unknownMethod,openFile 传 ../ 得到 plugin.web.forbidden。在设置 › 插件里停用该插件,窗口消失;重新启用后窗口回到原来的停靠位置并重新加载页面。打开过网页窗口后运行 scripts/verify-runtime-bundle-immutability.sh 通过。摄像头与麦克风:自动回归 ./scripts/test-macos.sh --filter 'PluginMediaCapture|MockCapture|WebMedia',插件页在真实 WKWebView 里用 WebKit 模拟设备验证:用户点 Allow 才授权且问题记录的请求者是插件名加 ID,拒绝得到 NotAllowedError,缺用途说明、页面不在窗口里、非本插件主框架一律不弹窗直接拒绝,隐藏窗口后采集结束,点 Allow 之后页面才被隐藏时才启动的设备也被立即停掉,隐藏之后才点的 Allow 不生效,源码扫描保证每个 WKWebView 都有人回答请求且 .grant 只出现在 WebMediaCapture.swift;补上 Info.plist 用途说明后,在开发版里用调用 getUserMedia 的插件页确认弹窗写明插件和 ID、隐藏工具窗口后系统菜单栏的绿点或橙点消失、再显示后同一页面第二次调用是否再询问(WebKit 可能复用同一文档里已批准的授权,按实测校对弹窗说明)(当前构建没有用途说明,页面里 navigator.mediaDevices 不存在,这一步尚不能做)。Windows:ui 2 宿主已实现 state/openFile 与 locale/theme。运行 WindowsRust 和 Frontend 计时 harness,验证包外来源拒绝、内部/外部 junction 的真实目标边界、原生桥脚本的主题初值与事件;在真实 WebView2 中检查同包导航可用,外站导航、弹窗和下载被拒绝,外站得不到桥;切换语言和主题,确认 context、data-theme 与 --lithe-* 同步。设置页与 Web 窗口视觉验收仍 pending,不能以脚本测试代替。 Windows ACL 回归读取实际编译的 Tauri authority:插件私有来源仅允许 declarative-web request,拒绝 fs/http/event 及常规宿主命令,外站与主窗口不能借此调用桥;实机必须验证 state 往返成功,不能只验证拒绝分支。 Windows 请求层:页面加载前安装 WebResourceRequested 过滤器;本机 HTTP 计数器必须证明外站导航 GET 为零,不能仅以导航被取消或 URL 不变判断隔离成功。 Windows 双工作区回归:同一插件的页面和 state 分别归属创建窗口及工作区;A 页 openFile 只在 A 编辑器打开,切换工作区、路径拒绝或编辑器失败须向页面返回错误,不能先返回成功。关闭 A 清理其页面/状态但保留 B;禁用插件同时撤销全部页面与待处理请求,包变更等待资源读取退出。页面仍使用独立 WebView,停靠及布局恢复保持 partial。 关闭工作区与页面写状态/打开文件交错时,先原子撤销所属页面,再清状态与待确认请求;旧页面实例和关闭前发起的异步页面创建都必须被拒绝,重新打开同一工作区的新请求仍可用。" + "verification": "自动回归:文件对话框见 PluginWebBridgeTests 的 fileDialogsNameThePluginAndReturnOnlyTheChosenPath 与 oneFileDialogAtATimeAndAClosedPageLearnsNoChoice;LITHE_TEST_WINDOW_MODE=isolated ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginWeb|PluginManagerTests|PluginDiskInstallationTests|BuildSystemServiceTests',检查包内脚本、CSP、主题事件、nonPersistent 数据存储和包文件字节不变;验证错误方法/参数、跨插件/工作区/符号链接逃逸、激活中工作区切换、等待期限、关闭后释放、真实 resetDedicatedWindowSession 在设置窗口仍强持项目模型且网页隐藏时同步关闭页面并清空登记表(TestGate 挂起检测和模块退出,待处理/退出中/退出后请求均返回 plugin.web.forbidden,桥接激活次数不增且退出后 Execution 未重新激活;新回归编译和运行待 CI 验证)、拒绝启动时不认领其他运行、任务与测试两入口遇 busy 均不取消现有运行或清空输出(含 TestGate 控制等待确认期间出现新运行)、同名 demo application/example 依完整 tags 唯一选中且最终参数来自模型、真实 Cargo 模型经桥接选中 example 后通过 Core targetTags 校验并生成 run --example demo、缺少区分信息或伪造/错误类型 tags 被拒绝、停用后旧视图无法重建且工作区关闭不撤销停用、安装和回滚校验失败保留旧页面、社区磁盘同版本替换及升级经真实审查和管理器入口在修改资源及安装记录前同步关闭双项目隐藏页面,拒绝已批准的进程许可并阻止后注册窗口及重新启用在重启前重建(PluginDiskInstallationTests/diskReplacementClosesEveryProjectPageBeforeMutation;篡改快照校验失败保留旧页面)、替换前关闭页面、模型任务类别及声明运行项完整转发、双项目页面与同意记录同步失效、后注册窗口继承阻断、包变更后旧管理器启用不能解锁、弱引用不保留关闭窗口。node scripts/test-reuse-worktree-resources.mjs 拒绝网页状态跨工作树复用。产品界面验收仍 pending。macOS:在开发版里用“从磁盘安装插件”安装 web-panel 夹具(plugin.json 旁放 ui/index.html 和 icons/panel.svg),重启后打开右侧 Web Panel 窗口,确认页面显示、根元素带 data-theme 和 --lithe-* 变量,切换浅色/深色后 ui.themeChanged 到达;页面里的内联脚本和 fetch 外网被 CSP 拦截。在页面控制台调用 lithe.request('ui.setState', {state:{a:1}}),隐藏再显示窗口后 ui.getState 仍返回该值;调用未知方法得到 plugin.web.unknownMethod,openFile 传 ../ 得到 plugin.web.forbidden。在设置 › 插件里停用该插件,窗口消失;重新启用后窗口回到原来的停靠位置并重新加载页面。打开过网页窗口后运行 scripts/verify-runtime-bundle-immutability.sh 通过。摄像头与麦克风:自动回归 ./scripts/test-macos.sh --filter 'PluginMediaCapture|MockCapture|WebMedia',插件页在真实 WKWebView 里用 WebKit 模拟设备验证:用户点 Allow 才授权且问题记录的请求者是插件名加 ID,拒绝得到 NotAllowedError,缺用途说明、页面不在窗口里、非本插件主框架一律不弹窗直接拒绝,隐藏窗口后采集结束,点 Allow 之后页面才被隐藏时才启动的设备也被立即停掉,隐藏之后才点的 Allow 不生效,源码扫描保证每个 WKWebView 都有人回答请求且 .grant 只出现在 WebMediaCapture.swift;补上 Info.plist 用途说明后,在开发版里用调用 getUserMedia 的插件页确认弹窗写明插件和 ID、隐藏工具窗口后系统菜单栏的绿点或橙点消失、再显示后同一页面第二次调用是否再询问(WebKit 可能复用同一文档里已批准的授权,按实测校对弹窗说明)(当前构建没有用途说明,页面里 navigator.mediaDevices 不存在,这一步尚不能做)。Windows:ui 2 宿主已实现 state/openFile 与 locale/theme。运行 WindowsRust 和 Frontend 计时 harness,验证包外来源拒绝、内部/外部 junction 的真实目标边界、原生桥脚本的主题初值与事件;在真实 WebView2 中检查同包导航可用,外站导航、弹窗和下载被拒绝,外站得不到桥;切换语言和主题,确认 context、data-theme 与 --lithe-* 同步。设置页与 Web 窗口视觉验收仍 pending,不能以脚本测试代替。 Windows ACL 回归读取实际编译的 Tauri authority:插件私有来源仅允许 declarative-web request,拒绝 fs/http/event 及常规宿主命令,外站与主窗口不能借此调用桥;实机必须验证 state 往返成功,不能只验证拒绝分支。 Windows 请求层:页面加载前安装 WebResourceRequested 过滤器;本机 HTTP 计数器必须证明外站导航 GET 为零,不能仅以导航被取消或 URL 不变判断隔离成功。 Windows 双工作区回归:同一插件的页面和 state 分别归属创建窗口及工作区;A 页 openFile 只在 A 编辑器打开,切换工作区、路径拒绝或编辑器失败须向页面返回错误,不能先返回成功。关闭 A 清理其页面/状态但保留 B;禁用插件同时撤销全部页面与待处理请求,包变更等待资源读取退出。页面仍使用独立 WebView,停靠及布局恢复保持 partial。 关闭工作区与页面写状态/打开文件交错时,先原子撤销所属页面,再清状态与待确认请求;旧页面实例和关闭前发起的异步页面创建都必须被拒绝,重新打开同一工作区的新请求仍可用。 Windows R5:逐项计时验证原生 build 请求解析、页面身份/并发/期限/撤销和选项持久化守卫;前端独立进程验证按窗口工作区接收、迟到 listener 清理、拒绝/超时/旧回复和实例视图同步,openFile/state 回归保持。此子集未做真实构建包 WebView2 操作验收,整体仍 partial/pending。 Windows R7:运行 Frontend 计时 harness,选择 plugin-web-build.test.ts、plugin-web-build-workflow.test.ts、plugin-web-build-approval.test.tsx 和 plugin-web-appearance.test.ts;验证监听器到真实 BuildRunSession/BuildDependenciesSession/loadBuildModel 的保存→同步→原生审批→输出订阅→再次同步→执行顺序,拒绝/迟到审批与订阅、撤销、busy、清理失败的原 owner 重试以及真实审批弹窗的身份/程序/风险展示和关闭。WindowsRust 验证 web_build_tests 的参数/选择守卫与指定实例静态刷新、compiled_acl_grants_only_the_bridge_to_plugin_private_origins 对新增宿主 action 的拒绝;统一跑宿主 Rust 和正式 Release。最后用真实 WebView2 页面执行七种 build 方法并检查两页/两窗隔离、契约回包/事件、超时取消、输出窗和无遗留进程;缺少原生证据前不得改为 verified。 Windows R18:独立x64 Release基线272f2e3f、exe SHA256 c488037ab0cf18a4e2e9bda10aaf60e2302a37a91498af0096e95bedfba0ba36完成受控ui:2状态桥9阶段真实验收:完整unsigned安装审查/零程序、真实私有WebView2、CDP精确一次IPC POST故障后原Promise经真实postMessage成功、后续getState读回nonce且POST仍1次。固定warning1次、真实callback Map三次均0、冻结transport描述符/函数摘要不变;正常关闭卸载退出,发行镜像/包/ZIP不变、profile和owned进程清空。helper10项只证明匹配和清理,不能代替原生传输;该产品证据不是最终集成树运行,也不证明物理网络、ui1/functions/ui3、arm64或正式签名完整包;保持partial/pending与全部gate。 Windows R19:ui:1 字面文本安全验收使用一次调用生成的完整 unsigned API3 包,仅声明 ui.contribute;计数服务先绑定 loopback 端口并做正向控制,再生成 ZIP 和绑定当前源码身份的 plan。正式入口必须冻结构建前后源码清单并绑定可执行文件、配置和提交,不能给旧二进制补造当前 identity。打开私有页面前订阅浏览器请求,用原始 Document 和桥脚本两条请求防止空迹通过;比较文本、DOM、CSP、冻结 transport、回调排空及包和发行镜像字节。浏览器请求观察只能证明其可见的网络请求,不能把未观察到请求或空回调 Map 写成精确零 postMessage IPC。辅助测试仅验证失败边界。R20 正式入口在资源校验前缺 native Bun PATH,依赖恢复经历 TLS 失败;完成依赖/helper/frontend 后,短命进程检查竞态又中断构建,最后耗尽 90 分钟总预算,未产出验收 EXE。九个真实运行时步骤均未执行。R21 已补构建前 native bun.exe 定位与固定版本检查,安装局部预算由验收入口经 build 传到 installer(每次 1~1200 秒、默认 300 秒);短命进程仅在原进程退出或句柄已触发等正向证明下忽略,仍在运行却无法检查时失败。Tauri 结束后通过同一个独占文件句柄比较 Cargo.toml:原字节不写,只有精确 CRLF→LF 结果才恢复;语义编辑、占用或删除均保留现状并拒绝 identity,构建原错和检查错同时保留。13 项准备回归及进程 owner 回归通过;这只修复工具,未运行本轮 Cargo/Release/产品,仍须下一轮一次正式构建和九阶段验收。真实安装、文本页、隐藏重开和退出仍 pending,不能据此升级 M10。 Windows R22:R22 唯一正式入口因 PATH 前置 MSYS 而选中 GNU tar,在 JDTLS 解压阶段中断,未进入 Cargo、未产出 EXE,九个真实步骤全部未执行。准备脚本现从平台 SystemDirectory 定位原生 tar,不依赖 PATH;缺失在缓存/输出变更前失败,解压非零保留原生错误并停止后续复制。16 项离线准备回归通过,涵盖绝对盘符/空格路径的小 tar.gz/VSIX、假 PATH tar、坏归档及提前缺失;这仍只证明工具边界,必须另做一次正式产品验收。 R23 正式入口已通过原生 tar 的 JDTLS 准备,随后依赖安装先耗尽 1200 秒局部预算,既有的一次 clean retry 又在 tree-sitter-cli 的 GitHub 二进制下载遇到 TLS ECONNRESET。尚未进入 Cargo,没有 EXE 或构建 identity,ui:1 九阶段仍为 0/9,不能用准备回归或旧 Release 补足。该下载由 npm 包的 install.js 另行发起,npm tgz 的 SRI 不证明 GitHub 二进制;修复应先核实当前 Node 与子进程实际采用的代理/重定向路径,保持 TLS 校验,再在同一任务内完成完整安装、冻结源码、构建和一次正式验收。 R24 的真实 Node 24.15 子进程已验证两个 tree-sitter-cli 版本的 CONNECT、受系统信任的 TLS 以及 302/200 响应头,但探针随即停止读取;响应头不能证明完整下载、gzip 或 lifecycle 安装成功。本轮安装还观察到 tree-sitter-c-sharp 与 @xterm/xterm 的 npm tarball SRI 完整性错误,随后主动取消;不能把两条错误分别记为首次及内部重试。降低并发后的另一次安装达到 1200 秒期限,日志不足以定位停在何处。因此未进入正式构建,ui:1 九阶段继续 0/9 pending。下一步先对比 lock 固定镜像与 npm 官方源的完整字节/SRI,再验证两个真实 install.js 的完整下载;不得只凭 HTTP 200、重设相同代理或扩大期限宣称已修复。v0.23.2 官方资产摘要仍为 null,未建立二进制复用路径,45 项离线/helper 通过只证明辅助边界。 针对失败包的四次完整传输补证表明,官方源的 tree-sitter-c-sharp@0.23.5(3,685,327 字节)和 @xterm/xterm@6.0.0(1,418,562 字节)均匹配当前 lock SRI,包内精确版本及依赖声明也一致,无需因此修改版本或这两个节点的依赖。镜像 C# 包在本地 60 秒截止前未完成,不能把截止触发的 reset 当作远端主动重置;xterm 两源完整字节相同。xterm 官方 metadata 的 TLS 请求失败,只有 C# 另获官方 metadata integrity/shasum;这些证据支持验证官方来源方案,尚未证明 Bun 能覆盖 lock 显式 URL、全量安装或 GitHub lifecycle 下载成功。 R25:冻结提交 2193e60a4cc3df484c4ee129dc437b992b330548 完成官方 npm 来源 frozen 全量安装、原始 tree-sitter-cli 两版本 lifecycle、当前源码 Windows x64 Release 和受控 unsigned API3/ui:1 字面文本九阶段 9/9。正式入口四次,第三次产品通过但报告聚合失败,第四次 ResumeBuild 修复归档后退出 0;源码库存/EXE/配置身份、DOM/CSP/请求及正控、回调排空、资源哈希和 owned 清理均已核实。原 lane 的 zsh 门禁未运行,仅等价扫描及真实资源不变性通过;集成树另跑原脚本,不覆盖原记录。集成审查另补非法代理在环境修改前脱敏拒绝、安装夹具 preload 复制及保留原进程句柄的回归验证;正式产品证据仍绑定 2193e60,不把审查修复后的新 SHA 冒充已重建。ui:3/vcsProviders/functions/buildProviders/buildSystems、签名完整包、arm64 及完整 M10 继续 pending,矩阵保持 partial/pending。 Windows 当前集成基线 04f411889d4264343fb2e548619543f5136a25d5 已完成正式 x64 Release,5011 项源码库存与 EXE/配置身份冻结;ui:1 九阶段、默认/LSP 生命周期 19 项和受控设置九阶段通过,八个安装目录检查点与对应 ZIP 字节一致。设置完整入口按设计非零退出,真实 functions/完整 M10 仍被前提阻断。ui:2 原入口因漏算五个 third_party 输入启动前失败;归档适配入口仅换用既有完整库存计算器,重新计算并与原 marker 精确匹配后,原生回退链九阶段通过。源码清单现统一包含 tracked 与未忽略新输入,web helper 13 项、text 兼容 12 项及真实根目录两 lane 5011 项一致;这些后续 helper 修复不冒充新 SHA 的运行时证据。发行镜像/包/ZIP 字节未变,owned 进程及隔离 profile 已清理;受控 LSP 不证明真实 JDT/Provider 完整包,ui:3/functions/vcsProviders/buildProviders/buildSystems、正式签名完整包、arm64 与完整 M10 继续 pending。", + "notes": "Windows R7 在 R5 的实例/选项桥上接通既有契约的 refresh、runTask、runTests、cancel、dependencies。网页选择由原生当前模型唯一匹配为 ID,完整 tags 校验且忽略页面伪造的 execution/properties;保存、同步、审批、输出订阅、执行、取消和清理继续由现有模型/运行/依赖 session 与原生 owner 负责。静态刷新只重载指定实例,不重新检测或取消其他插件。原生窗口、工作区与页面代次、完整包摘要和 operation 身份逐阶段复验;失败清理保留同一 owner 与 Stop 重试。instancesChanged 从同一 registry 的实际视图变化发送,runChanged 只发送既有状态字段,网页不能调用宿主 action IPC。构建/Provider 能力开关保持关闭;Provider 来源取决于同轮 Provider 生产端口接通,service/application 计划仍由已有运行端口拒绝。此工作树未具备前端依赖,尚未执行 R7 前端测试/类型检查;Rust 与 Release 由统一集成工作树验证。真实 WebView2、视觉与完整包工作流验收仍 pending,整体 partial/pending。", + "lastReviewed": "2026-10-29" } diff --git a/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json b/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json index 6eb5aa3c55..a7da994ebc 100644 --- a/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json +++ b/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json @@ -40,12 +40,68 @@ "rust/lithe-core/src/vcs", "rust/lithe-core/src/lsp/interface/vcs_provider.rs", "rust/lithe-core/src/tests/vcs.rs", - "shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json" + "shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json", + "windows/tauri/src-tauri/src/plugin_host/vcs.rs", + "windows/tauri/src-tauri/src/plugin_host/vcs_registry.rs", + "windows/tauri/src-tauri/src/plugin_host/vcs_tests.rs", + "windows/tauri/src/features/vcs/vcs-root-controller.ts", + "windows/tauri/src/features/vcs/vcs-root-controller.test.ts", + "windows/tauri/src/features/vcs/vcs-root-service.ts", + "windows/tauri/src/features/vcs/vcs-root-service.test.ts", + "windows/tauri/src/features/vcs/vcs-root-sources.tsx", + "windows/tauri/src/features/git/stores/git-repository.store.ts", + "windows/tauri/src/features/git/stores/git-repository.store.test.ts", + "windows/tauri/src/features/workspace/services/workspace-lifecycle.ts", + "windows/tauri/src/features/git/components/git-view.tsx", + "windows/tauri/src/features/vcs/vcs-root-sources.test.tsx", + "windows/tauri/src-tauri/src/project_window_registry.rs", + "windows/tauri/src/features/workspace/services/workspace-lifecycle.test.ts", + "windows/tauri/src-tauri/src/plugin_host/vcs_session.rs", + "windows/tauri/src-tauri/src/plugin_host/vcs_status.rs", + "windows/tauri/src-tauri/src/plugin_host/vcs_session_tests.rs", + "windows/tauri/src-tauri/src/plugin_host/vcs_session_core_tests.rs", + "windows/tauri/src-tauri/src/plugin_host/vcs_session_fixture.mjs", + "windows/tauri/src-tauri/src/plugin_host/vcs_status_tests.rs", + "windows/tauri/src-tauri/src/core.rs", + "windows/tauri/src/features/vcs/vcs-status-controller.ts", + "windows/tauri/src/features/vcs/vcs-status-controller.test.ts", + "windows/tauri/src/features/vcs/vcs-status-panel.tsx", + "windows/tauri/src-tauri/src/plugin_host/vcs_mappings.rs", + "windows/tauri/src-tauri/src/plugin_host/vcs_mappings_tests.rs", + "windows/tauri/src-tauri/src/plugin_host/vcs_watch.rs", + "windows/tauri/src-tauri/src/plugin_host/vcs_watch_tests.rs", + "windows/tauri/src/features/vcs/vcs-mapping-controller.ts", + "windows/tauri/src/features/vcs/vcs-mapping-panel.tsx", + "windows/tauri/src/features/vcs/vcs-refresh-queue.ts", + "windows/tauri/src/features/vcs/vcs-mappings-watch.test.ts", + "shared/fixtures/vcs/windows-root-mappings-v1.json", + "windows/tauri/src-tauri/src/plugin_host/vcs_query.rs", + "windows/tauri/src-tauri/src/plugin_host/vcs_query_tests.rs", + "windows/tauri/src/features/vcs/vcs-query-controller.ts", + "windows/tauri/src/features/vcs/vcs-query-controller.test.ts", + "windows/tauri/src/features/vcs/vcs-query-types.ts", + "windows/tauri/src/features/vcs/vcs-history-panel.tsx", + "windows/tauri/src/features/vcs/vcs-history-panel.test.tsx", + "windows/tauri/src-tauri/src/plugin_host/vcs_actions.rs", + "windows/tauri/src-tauri/src/plugin_host/vcs_actions_tests.rs", + "windows/tauri/src/features/vcs/vcs-action-types.ts", + "windows/tauri/src/features/vcs/vcs-action-controller.ts", + "windows/tauri/src/features/vcs/vcs-action-controller.test.ts", + "windows/tauri/src/features/vcs/vcs-resolve-cleanup.test.ts", + "windows/tauri/src/features/vcs/vcs-actions-panel.tsx", + "windows/tauri/src/features/vcs/vcs-actions-panel.test.tsx", + "scripts/test-windows-plugin-vcs-core.mjs", + "windows/tauri/src/features/vcs/vcs-file-operations.test.ts", + "scripts/plugin-host-acceptance/vcs_move_diagnostic.py", + "scripts/plugin-host-acceptance/vcs_move_diagnostic_test.py", + "scripts/plugin-host-acceptance/vcs-move-diagnostic-worker.mjs", + "scripts/plugin-host-acceptance/vcs-move-diagnostic_test.mjs" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "partial", + "verificationStatus": "pending" }, "owner": "Git", - "verification": "Core 共享层自动化:cargo test -p lithe-core vcs、cargo test -p lithe-core --lib lsp 与 scripts/verify-shared-contracts.sh,覆盖 vcsProviders 清单校验与 git 保留、onVcsRoot 激活、svn 根识别(嵌套、歧义目录与映射、git worktree 文件、工作区位于工作副本内部、剪枝目录、深度上限)、vcs.ownerOf 最深根归属、litheVcs 只向插件会话提供、协商缺必需能力时拒绝、lsp.vcsRequest 按操作生成请求与能力检查、Provider 错误码白名单、vcs.decodeReply 的回复校验用例;Node SDK 的 litheVcs 与示例 Provider 端到端测试在 lithe-plugin-docs 运行(scripts/sdk-e2e.mjs)。Subversion 社区插件:独立源码由 https://github.com/Lithe-IDEA/lithe-plugin-svn 维护;插件 CI 校验固定 SDK 的源码哈希,用 svn 录制的 status/info/log/blame --xml 与错误输出样例覆盖解析和错误码映射,并按 sdk.lock.json 的宿主提交构建 Core 桥接,在 svnadmin create 建立的 file:// 仓库上经 Core 跑通状态(含 changelist 中的路径)、提交、过期提交、更新与冲突、resolve、回滚、移动(未版本化文件不覆盖已有目标)、删除、历史分页、Annotate 和二进制文件;固定宿主基线不代表已验证未来 preview。主仓库只保留通用 VCS 契约回归(清单 fixture 与 Core 测试)。macOS 宿主自动化:VcsProviderHostTests 覆盖根识别与状态着色、每个根单独监听元数据目录(含包住工作区的工作副本)、工作区内新出现的工作副本重新识别、项目树移动两端都归同一个根才交给 Provider、等待归属判断时开始别的操作则退回磁盘移动、找不到 VCS 客户端时报 vcs.tool.unavailable、关闭项目窗口后停止 Provider 且之后的工作区加载不再启动、工具窗口使用通用版本控制图标。宿主验收:从磁盘或 Lithe Store 安装 Subversion 社区插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。 VcsHistoryListTests 覆盖修订去重、耗尽/无进展/循环 cursor、失败保留与显式同页重试、在途去重、重置旧回复及取消;VcsProviderHostTests 使用 fake Provider 和真实 SwiftUI/NSScrollView 验证工作副本/单文件历史自动请求第二页、50条限额、追加偏移不动和耗尽停止。真实插件历史仍需 macOS 实机连续滚动验收,不据组件测试标记全部能力已验证。", - "notes": "Issue #827。Rust Core 已实现 vcs.detectRoots、vcs.ownerOf、vcs.decodeReply、lsp.vcsRequest 与 litheVcs 协商,Node SDK 的 litheVcs 支持随 SDK 迁到 lithe-plugin-docs(P1);Subversion 社区插件在独立仓库 Lithe-IDEA/lithe-plugin-svn 实现全部 11 种操作,ID 为 io.github.lithe-idea.svn-support,不需要 Lithe 签名(P2)。macOS 宿主已声明 vcsProviders:打开工作区时识别根并记住歧义目录的选择,插件的左侧工具窗口使用通用版本控制图标,首次读取状态时显示读取中,列出冲突、变更和未版本化文件,行支持辅助功能的选择、打开和勾选操作,支持勾选提交、更新、回滚、标记解决、清理、文件与工作副本历史、与基线比较;项目树按 Provider 状态着色并在右键菜单提供比较,项目树中的重命名和移动在两端都属于同一个根时交给 Provider 跟踪;项目树、搜索和索引像 .git 一样隐藏 .svn,每个根单独监听自己的元数据目录(含工作区位于工作副本内部时的上层 .svn),工作区里新检出的工作副本会被识别;找不到 svn 客户端时提示安装;关闭项目窗口即停止该窗口的 Provider;安装审核显示 Provider 的启动命令、客户端命令和是否联网(P3)。macOS 尚未接入编辑器 gutter 与 Annotate,所以记为部分实现;插件要等带 vcsProviders 能力的 Lithe 版本发布后再在 Lithe Store 上架。Windows 宿主尚未接入。分阶段计划见提议 Note。" + "verification": "Core 共享层自动化:cargo test -p lithe-core vcs、cargo test -p lithe-core --lib lsp 与 scripts/verify-shared-contracts.sh,覆盖 vcsProviders 清单校验与 git 保留、onVcsRoot 激活、svn 根识别(嵌套、歧义目录与映射、git worktree 文件、工作区位于工作副本内部、剪枝目录、深度上限)、vcs.ownerOf 最深根归属、litheVcs 只向插件会话提供、协商缺必需能力时拒绝、lsp.vcsRequest 按操作生成请求与能力检查、Provider 错误码白名单、vcs.decodeReply 的回复校验用例;Node SDK 的 litheVcs 与示例 Provider 端到端测试在 lithe-plugin-docs 运行(scripts/sdk-e2e.mjs)。Subversion 社区插件:独立源码由 https://github.com/Lithe-IDEA/lithe-plugin-svn 维护;插件 CI 校验固定 SDK 的源码哈希,用 svn 录制的 status/info/log/blame --xml 与错误输出样例覆盖解析和错误码映射,并按 sdk.lock.json 的宿主提交构建 Core 桥接,在 svnadmin create 建立的 file:// 仓库上经 Core 跑通状态(含 changelist 中的路径)、提交、过期提交、更新与冲突、resolve、回滚、移动(未版本化文件不覆盖已有目标)、删除、历史分页、Annotate 和二进制文件;固定宿主基线不代表已验证未来 preview。主仓库只保留通用 VCS 契约回归(清单 fixture 与 Core 测试)。macOS 宿主自动化:VcsProviderHostTests 覆盖根识别与状态着色、每个根单独监听元数据目录(含包住工作区的工作副本)、工作区内新出现的工作副本重新识别、项目树移动两端都归同一个根才交给 Provider、等待归属判断时开始别的操作则退回磁盘移动、找不到 VCS 客户端时报 vcs.tool.unavailable、关闭项目窗口后停止 Provider 且之后的工作区加载不再启动、工具窗口使用通用版本控制图标。宿主验收:从磁盘或 Lithe Store 安装 Subversion 社区插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。 VcsHistoryListTests 覆盖修订去重、耗尽/无进展/循环 cursor、失败保留与显式同页重试、在途去重、重置旧回复及取消;VcsProviderHostTests 使用 fake Provider 和真实 SwiftUI/NSScrollView 验证工作副本/单文件历史自动请求第二页、50条限额、追加偏移不动和耗尽停止。真实插件历史仍需 macOS 实机连续滚动验收,不据组件测试标记全部能力已验证。 Core 共享层自动化:cargo test -p lithe-core vcs、cargo test -p lithe-core --lib lsp 与 scripts/verify-shared-contracts.sh,覆盖 vcsProviders 清单校验与 git 保留、onVcsRoot 激活、svn 根识别(嵌套、歧义目录与映射、git worktree 文件、工作区位于工作副本内部、剪枝目录、深度上限)、vcs.ownerOf 最深根归属、litheVcs 只向插件会话提供、协商缺必需能力时拒绝、lsp.vcsRequest 按操作生成请求与能力检查、Provider 错误码白名单、vcs.decodeReply 的回复校验用例;Node SDK 的 litheVcs 与示例 Provider 端到端测试在 lithe-plugin-docs 运行(scripts/sdk-e2e.mjs)。Subversion 社区插件:独立源码由 https://github.com/Lithe-IDEA/lithe-plugin-svn 维护;插件 CI 校验固定 SDK 的源码哈希,用 svn 录制的 status/info/log/blame --xml 与错误输出样例覆盖解析和错误码映射,并按 sdk.lock.json 的宿主提交构建 Core 桥接,在 svnadmin create 建立的 file:// 仓库上经 Core 跑通状态(含 changelist 中的路径)、提交、过期提交、更新与冲突、resolve、回滚、移动(未版本化文件不覆盖已有目标)、删除、历史分页、Annotate 和二进制文件;固定宿主基线不代表已验证未来 preview。主仓库只保留通用 VCS 契约回归(清单 fixture 与 Core 测试)。macOS 宿主自动化:VcsProviderHostTests 覆盖根识别与状态着色、每个根单独监听元数据目录(含包住工作区的工作副本)、工作区内新出现的工作副本重新识别、项目树移动两端都归同一个根才交给 Provider、等待归属判断时开始别的操作则退回磁盘移动、找不到 VCS 客户端时报 vcs.tool.unavailable、关闭项目窗口后停止 Provider 且之后的工作区加载不再启动、工具窗口使用通用版本控制图标。宿主验收:从磁盘或 Lithe Store 安装 Subversion 社区插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。 Windows R8:Frontend 计时 harness 选择 vcs-root-controller.test.ts 和 vcs-root-service.test.ts,验证真实 workspaceRuntimeRegistry 接线、同路径重开 incarnation、插件失效、工作区删除、迟到扫描/owner 回复、手动截止期限与来源清空;依赖就绪后跑 git-repository.store.test.ts,检查插件来源不会进入 Git 路径或改变 activeRepoPath。WindowsRust 运行 plugin_host::vcs::tests,真实 Core 覆盖 Git/SVN 嵌套、混合歧义、包住工作区的根、被排除清单、生成目录剪枝、跨根移动无共同 owner、截断、旧扫描/插件失效/期限/关闭/窗口隔离。统一宿主与 Release 验证后,再在真实工作区观察只读来源列表与插件停用;开关关闭时不应注册不支持的插件,不得把纯 controller 测试当作 Provider 端到端证据。 R8 补充验证:project_window_registry 的被动 registration 查询必须拒绝别的目录、窗口、工作区 ID,release/reclaim 必须更换 ID;vcs_root native ticket 绑定它并拒绝先前登记及晚入队的旧扫描。vcs-root-sources.test.tsx 检查普通版本控制窗口组件内的插件根选择和当前文件归属实际调用,歧义/工作区外文件/失效清空;依赖不齐时保持未验证。窗口 UI 四个分支都应可达(无活动 Git、加载、失败、正常),真实 UI 仍待目标平台验收。 Windows R9:运行 Frontend 计时 harness 的 vcs-status-controller、vcs-root-controller、vcs-root-service 和依赖就绪后的 vcs-root-sources 测试,覆盖精确根/代次、显式读取、取消截止期限、晚结果、回收失败重试、多窗口和 UI 控件;WindowsRust 运行 plugin_host::vcs::session::tests 与 plugin_host::vcs::status::tests 和已有被动根测试,覆盖真实 Core decoder/ownerOf 投影、Provider 协商事件与请求形状、失败启动、未结束 worker/失败 destroy 仍保留 NativeLease、前端通用 Core ACL 和安装 permission。完整 Core initialize/线协议回归继续由 rust/lithe-core/src/lsp/interface/engine.rs 的 VCS 测试负责;真实安装插件、系统客户端和进程树端到端、Release 已由主窗口 R11 统一集成通过。 主窗口 R9:Tauri 宿主与 Core LSP/VCS 受影响测试已逐项计时通过,Windows x64 Release 构建通过。新增普通路径/canonical 前缀归一、删除/移动不存在路径、窗口清理结束后迟到启动、销毁 waiter 超时后的成功证明,以及旧 cleanup 不能停止/移除替代 owner 的回归。真实安装 Provider、客户端及进程树完整包端到端仍 pending。 Windows R10:Frontend 逐项计时选择 vcs-mappings-watch、vcs-root-controller、vcs-root-service、vcs-status-controller;依赖完整后补 vcs-root-sources 的映射来源/明确保存/冲突草稿交互。WindowsRust 主窗口运行 mappings::tests、watch::tests 及现有 VCS/R9 回归,真实 Core 覆盖持久化重开、嵌套 Git/跨根、父工作副本、标记删除、外部冲突、未知同 ID、目录替换、失效映射清除、监听预算/精确代次隔离。共享 fixture windows-root-mappings-v1.json 固定限制与归属;资源复用测试必须拒绝 vcs-root-mappings。此分支只执行轻量静态/前端计时检查,新增原生代码没有编译,真实文件监听、Provider/客户端、进程树及完整包仍 pending,不能继承主窗口 R9 Release 结果作为 R10 证据。 主窗口 R10:组件/控制器、映射/watch 原生及底层项目文件监听逐项回归、完整 TypeScript 和 Windows x64 Release 已通过。修复窗口先删监听后撤销 registry 的晚安装泄漏,受控回归验证撤销前后迟到发布均回收或拒绝、其它窗口保持。完整 VCS 插件/系统客户端/进程树端到端、OS 监听恢复与发行资源工作流哈希实机验收仍 pending。 Windows R11:Frontend 逐项计时覆盖 vcs-query-controller、vcs-history-panel 和 vcs-root-sources,验证 Provider 游标/顺序/分页预算、最多 500 个可见变更选择、文件与工作副本历史、Stop/Retry cleanup、卸载与根/来源切换取消、迟到比较不打开 Diff、删除/新增空侧、重命名 oldPath/currentPath 和二进制提示。WindowsRust 的 vcs::query 与 session 回归复用真实 Core decoder/ownerOf 和 typed read-only adapter,覆盖普通/canonical 路径、缺失文件及父目录、junction 现存/缺失逃逸、32 MiB 内容上限、短会话清理证明和 exact owner 重试。完整 TypeScript、共享契约、运行时 bundle 只读门禁和 Windows x64 Release 已由主窗口 R11 统一集成通过。真实 Provider/系统客户端/进程树、完整包与 WebView2 人工验收仍 pending。 R12:Frontend actions 确认/控制器及全部 VCS 回归、真实 Core 原生 actions/session/scope 测试通过;覆盖精确普通文件、未知结果、只重试清理、缺失/目录端点拒绝、隐式 added/replaced/renamed/copied 祖先拒绝,以及 Windows 大小写等别名跨嵌套根拒绝。真实包仍需手工验证普通文件提交/回滚、整根更新、取消、清理失败恢复及双窗口隔离。 主窗口 R12:36 个前端文件 325 项、Tauri 宿主最终 751 项通过及 4 项夹具跳过、Node 85 项通过及 1 项平台跳过;完整 TypeScript、共享契约、测试稳定性、Windows 边界、bundle 只读静态门禁和 Windows x64 Release 已通过。首轮两个新增夹具失败已修复并保留失败记录,最终受影响原生 208 项重验通过,并补最终 VCS 分类回归 65 项。真实安装 Provider/系统客户端与进程树、完整包、双窗口及 WebView2 人工验收、macOS 和 Windows arm64 仍 pending;受控测试与 x64 编译不能替代这些证据。 Windows R13:新增 actions/session 原生回归覆盖 conflicts/cleanup 实际协商、resolve 精确冲突端点与不展开 oldPath、cleanup 严格整根范围、一次性写入和确定失败/未知结果经过清理重试仍不重发;这些新增原生测试已随最终宿主计时测试通过。集成树 vcs-resolve-cleanup.test.ts 与 vcs-actions-panel.test.tsx 经 Frontend 计时 harness 聚焦 13 项通过。真实 Core stdio 夹具 vcs_session_core_tests.rs 已通过 scripts/test-windows-plugin-vcs-core.mjs 显式运行(706ms);该入口使用绝对 Node 身份与 R13 的 45 秒进程预算(R14 四个操作扩展为 90 秒),强制 --ignored 并要求唯一用例 passed,已接入 Windows CI。普通宿主测试中的跳过不能作为验证。该夹具使用受控 Provider,不代替生产插件包、系统 SVN、完整进程树工作流和 WebView2 人工验收,整体仍 partial/pending,ui:3/vcsProviders 均保持关闭。 Windows R14:新增 add/delete 界面与 typed fileOperation 请求。add 只接受根目录直属的现存普通 unversioned 文件,拒绝目录和隐式添加父目录;delete 复核全部现存普通文件端点并展示磁盘删除由 Provider 执行、无 keep-local/备份的风险。保持一次性 receipt、完整状态/包/原生 scope/Core ownerOf 复验、同根写互斥和仅清理原 owner 的重试;扩展端点的独立状态也必须满足安全范围。主窗口最终受影响 VCS 原生 76 项及前端 45 文件 387 项通过;显式 Core stdio 四操作用例通过(1304ms,90 秒预算)。首轮夹具误把 Core 合法 null 空回复判为非法,已改为数组非法回复并新增合法 null 回归,保留原失败报告。受控 Core stdio 不能替代系统 SVN 或正式包;整体仍 partial/pending,ui:3/vcsProviders 保持关闭。 Windows R15:固定 Subversion Provider v0.1.0 commit 3a558be049f47a3ebe24ae770faf7999df883c29 与 SDK 54598ae6e0b9fd71a07b77e1fea6917ba8897a6f,11 个 vendored 与独立上游文件摘要一致。六项一次性诊断展示目标/父目录/源可在预检后变化;18 次请求由当前控制器的不支持 operation 分支拒绝,原生调用为零,未验证竞态防护或执行 Provider/Core/SVN。本轮不新增 move 生产入口或长期回归 suite。v1 无原子拒绝覆盖保证,固定 Windows 包与 svn.exe、实际目录重定向/替换/源变化验收仍缺;move 与 vcsProviders 保持关闭,整体 partial/pending。 Windows R16:固定 Provider v0.1.0 commit 3a558be049f47a3ebe24ae770faf7999df883c29、历史 SDK 54598ae6e0b9fd71a07b77e1fea6917ba8897a6f,与官方签名 Apache SVN 1.15.0 直接 stdio 实跑三例。诊断事件门在真实 status 和 handler 进入后、SVN 启动前;已跟踪分支没有 Provider 目标预检。缺失叶子成功;目标后来出现文件时 E155033,字节/identity 不变但目标被调度 added;目标后来出现目录时成功却移入 target.txt/source.txt,精确叶子语义不成立。三项 passed 仅指诊断设备完成,不是安全验收或已提交 CI suite;一次性脚本未提供长期外层 watchdog/异常清理。未覆盖 Core/Tauri、正式 Windows 包、父目录/源竞态、生产进程树或安装资源不变,move/vcsProviders 保持关闭、partial/pending。 Windows R17:新增显式固定输入SVN move诊断入口,case/suite硬deadline、先Job归属后释放、零成员证明后才恢复读取,异常总清理且清理无证明时隔离保留;拒绝真实路径/junction输入输出重叠,最终哈希/读取失败写入失败JUnit。三真实case复现精确叶子成功、E155033但target schedule added、目录误嵌套;全部safetyPass=false,不计生产安全通过。源码stdio+官方SVN+wrapper gate不等于Windows正式Provider包或生产宿主验收。临时runtime/repo/WC/profiles/证据不可跨worktree复用,保持move/vcsProviders关闭与partial/pending。 Windows R18:固定官方SVN1.15.0源码commit e49d66ef91dd33409c7d78f82160d2976a939120(r1938480)静态追踪:move7四参数FALSE只在单源及稳定父路径下保留精确叶子解释;目标检查先于WC写锁,锁不能排除外部FS写入,metadata/rename/源删除分阶段。WIN32直调MoveFileExW(MOVEFILE_REPLACE_EXISTING),不具external-race no-replace保证,失败也可能有副作用;APR rename不在最终调用链。本轮修正换页符导致的引用偏移,42条物理LF引用已重验。不是VisualSVN二进制对应证明或真实竞态执行,不新增生产代码,不开放move/vcsProviders/ui:3,partial/pending保持。 Windows R19:截至 R19 采样,官方 v0.1.0 prerelease 仅列出完整 Darwin 源码插件包及校验和,未列出 Windows 包。ZIP 的完整 14 文件、tag 字节和 7 个 SDK 锁哈希已核对;这不证明与当前宿主动态兼容。完整 manifest 要求 serverProcess:1、vcsProviders:1,固定 Windows 宿主尚未声明后者。该包为 unsigned Community 来源,需用户明确批准;Marketplace 不接受 unsigned,GitHub 来源与校验和不能代替发布者签名。R20 在 ignored 的固定上游 main 717d23cd417f796d8487b3b7cbc8150a8356b512 上应用本地打包补丁,Windows x64 使用系统 bsdtar 3.8.9 顺序组装 darwin、windows-x86_64 目标:两 ZIP 各 14 个普通文件、CRC、7 个 SDK 锁哈希与 24 条静态导入闭包核对通过,payload 相同。manifest 仍为 0.1.0,完整 requires 未删减;这是本地非官方 unsigned 候选,Darwin 只是分发标签,未在 Darwin 执行。R21 的 ignored 上游补丁改为未发布 0.1.1 候选,已迁移 CI/release 的显式 platform/tag/output、四资产集合及 existing-release 严格校验,并在写入前拒绝输出与源码的真实路径/junction 交叠。两 ZIP 各 14 文件、7 SDK 锁哈希、24 导入边、CRC/SHA 与相同 payload 重验通过,12 项离线打包/发布分支检查通过。R22 以私有 staging 的 exclusive 新文件和 rename 替换 checksum 目录项,修复硬链接截断输入;README、锁定 SDK 和同 hash 硬链接回归及发布失败边界通过,五个真实 release shell 离线 gh 分支已纳入可携带的上游测试。15 项本地检查及双 ZIP 全量审计通过,11 文件完整补丁往返逐字节一致。审查另发现 P1:新增测试进程封装在 Popen 后才归属 Windows Job,快速子孙可能在分配前逃逸;当前 Job active=0 不能证明逃逸成员退出。需先归属再释放目标及确定性短命 root/失败回收回归,修复前上游补丁仍 pending,不作为可发布方案、不纳入主仓产品源码。未运行 Provider、产品、SVN、签名或发布;Windows 正式安装、依赖探测、initialize 和界面仍 pending,保持 partial/pending 与现有门控。 R23 的 ignored 上游打包补丁已修复上述先启动目标、后归属 Job 的竞态:先配置关闭即终止成员的非 breakaway Job,启动仅等待未触发事件的可信 Python bootstrap,归属成功后才释放目标。目标及子孙由内核继承 Job;清理使用保留的进程句柄和成员计数,不用历史 PID 猜测所有权。配置失败不得启动进程,归属失败不得启动目标;原始错误和清理错误同时保留。6 项 owner、9 项打包以及 zsh/bash 各 5 项真实 release shell 离线分支共 25 项通过,双 ZIP 全量审计及 12 文件完整补丁往返逐字节一致。复审未发现阻塞缺陷;这仍是未发布 unsigned 0.1.1 本地候选,POSIX 进程组清理没有在 Linux/macOS 内核实测,不作为跨平台运行、Provider/SVN 或正式发布证据,既有能力门控保持关闭。 上游打包候选的 R24 完整 12 文件补丁已经完成 Windows 本地源码交付,25 项去重回归、干净 clone 应用往返、四资产和双 ZIP 全字节验证通过,复审未发现阻塞缺陷。Linux 正常退出、超时与短命 root 后代清理三项回归已接入同一正式测试入口,CI/release 先运行并传播失败,报告以 always 条件上传;本机 Linux 环境缺工具,三项没有动态执行。MSYS zsh/bash 属 Windows 内核证据,不能填补 Linux/macOS 验收;维护者仍需应用候选并运行 Ubuntu CI、完成签名/安装及 Windows Provider 与宿主动态兼容验证。此结论不开放 vcsProviders/ui:3,也不代表已发布 0.1.1。", + "notes": "Issue #827。Rust Core 已实现 vcs.detectRoots、vcs.ownerOf、vcs.decodeReply、lsp.vcsRequest 与 litheVcs 协商,Node SDK 的 litheVcs 支持随 SDK 迁到 lithe-plugin-docs(P1);Subversion 社区插件在独立仓库 Lithe-IDEA/lithe-plugin-svn 实现全部 11 种操作,ID 为 io.github.lithe-idea.svn-support,不需要 Lithe 签名(P2)。macOS 宿主已声明 vcsProviders:打开工作区时识别根并记住歧义目录的选择,插件的左侧工具窗口使用通用版本控制图标,首次读取状态时显示读取中,列出冲突、变更和未版本化文件,行支持辅助功能的选择、打开和勾选操作,支持勾选提交、更新、回滚、标记解决、清理、文件与工作副本历史、与基线比较;项目树按 Provider 状态着色并在右键菜单提供比较,项目树中的重命名和移动在两端都属于同一个根时交给 Provider 跟踪;项目树、搜索和索引像 .git 一样隐藏 .svn,每个根单独监听自己的元数据目录(含工作区位于工作副本内部时的上层 .svn),工作区里新检出的工作副本会被识别;找不到 svn 客户端时提示安装;关闭项目窗口即停止该窗口的 Provider;安装审核显示 Provider 的启动命令、客户端命令和是否联网(P3)。macOS 尚未接入编辑器 gutter 与 Annotate,所以记为部分实现;插件要等带 vcsProviders 能力的 Lithe 版本发布后再在 Lithe Store 上架。Windows R8 已接通被动第一阶段:只读原生 registry 通过既有 vcs.detectRoots/vcs.ownerOf 识别根和逐路径归属,清单只来自 accepted/enabled 且完整包信任复验通过的插件;原生项目登记的目录 Handle、窗口、工作区 ID 和登记代次先校验,前端 incarnation 与扫描代次绑定该登记,停用/更新前同步撤销旧快照,关闭工作区与窗口拒绝迟到读取。仅当路径全属同一根且扫描完整时给出共同归属;歧义、跨根和截断均不认领为单个操作来源。普通版本控制窗口(含无 Git 仓库时)增加独立只读插件根/歧义目录选择器,当前编辑器文件通过生产 owners 端口查询归属,查询前 Core 重新识别并拒绝已变化的嵌套或歧义根,不把插件根交给 Git 分支、日志或写操作。vcsProviders 开关保持关闭,不启动 Provider,不实现提交/更新/回滚;映射编辑、文件监控、Provider 状态与动作后续接入。当前已运行 14 项前端 controller/service 与 25 项工作区生命周期计时测试;真实 Core 参与的新增宿主测试、依赖完整的 Git store/UI 测试、类型检查、原生界面和 Release 仍待统一集成验证,整体 partial/pending。分阶段计划见提议 Note。 Windows R9 在独立只读投影中新增手动读取 status:每次由本窗口、工作区 incarnation/扫描代次、精确根和完整包身份建立短会话;复用 Core 原生 LSP initialize 的 litheVcs 协商、lsp.vcsRequest 与 vcs.decodeReply,按真实安装授权和 process.vcs/languageServer.launch 声明执行,不借构建 Provider 身份或事件队列。请求完成后 stop/destroy 确认回收才发布结果;取消、超时、关闭和插件停用/更新保留 NativeLease 至启动 worker 与进程树均结束,失败保留原 owner 可重试。Core ownerOf 过滤嵌套 Git、歧义目录、工作区外路径和跨根移动两端;根扫描截断时拒绝读取。Git 分支/提交/日志不消费此投影,不增加写操作或自动监控。vcsProviders 能力表仍关闭,整体 partial/pending;主窗口已完成前端/原生计时回归及 x64 Release 编译;内部测试仍不能视为真实 Provider 完整包端到端或原生界面验收。 Windows R10 新增 Lithe 自有根映射的读取、草稿、明确保存及读回:来源为平台 app-local-data/vcs-roots,按工作区和根的原生目录身份绑定,既有 JSON adapter 保留未知字段/权限并以字节版本拒绝外部修改;同 ID 未来格式保守拒绝,重开恢复、目录替换不继承、unused 映射可清除。Core 仍独占 mappings/歧义/最深根规则。每个已验证工作区租用既有有界 FileWatcher,递归观察工作区以识别新根/删除,父工作副本根与祖先仅浅层监听,Provider 声明的 metadataPaths 为文件时监听父目录、为目录时只递归该元数据目录,不解析 SVN。每代只发一个失效提示;前端 750ms 合并、一条待刷新和一条正在运行的被动扫描,关闭/非 ready/窗口销毁/插件撤销释放租约,旧 scope/gen 无法清新 owner;无 accepted VCS Provider 时不安装监听。监听只撤销旧 status 并被动刷新,不启动 Provider 或工具。R9 原生短会话/lease/清理语义不变,真实能力表仍关闭。 Windows R11 增加只读文件/工作副本历史及基线比较,复用 R9 短会话及 Core typed request/decodeReply/ownerOf;结果在原进程回收证明后发布。监听、映射、根或完整包变化撤销旧查询,重试仅清理原 owner;分页保持 Provider 顺序。Diff 使用只读模型,支持新增/删除空侧和重命名原路径,二进制明确提示。R11 尚未接入写操作,后续进展见 R12/R13;vcsProviders 门控关闭。 Windows R12 接入精确 status receipt 的选中提交、update 和 revert:能力取自真实协商,确认展示根、全部双端路径、数量、冻结消息和风险;原生一次性消耗状态凭据,在独立短会话重新读取完整 status,对比快照并复验注册/根目录 identity/包许可与签名、Core ownerOf 后才写入。运行取消、未知结果和 cleanup failure 不自动重发,Retry Stop 只回收原 owner;完成或失败在原 scope 重新检测并刷新同根 status/原 history。整根 update 仅允许工作区恰好等于完整无歧义根;父工作副本、嵌套根、歧义与目录递归写入保守拒绝。watch 不触发写入;原窗口/scope/generation 的写入刷新标记持续到下一次完整检测,覆盖 owner 回收后延迟到达的元数据事件,其它窗口继续独立失效。明确的 Provider 失败与未知结果分别展示,失败或取消不重发写入。主窗口已通过前端/原生计时回归和 x64 Release。缺失端点无法凭状态证明文件类型,已删除文件和常见重命名写入保守拒绝;选中提交遇到 added/replaced/renamed/copied 祖先也拒绝,防止 Provider 隐式扩大范围。现存端点要求精确 canonical 拼写,拒绝 Windows 路径别名越过嵌套根。Windows R13 已接入 Core typed resolve 和 cleanup:resolve 按 conflicts 能力只处理所选现存冲突普通文件,支持 conflicted 状态或 treeConflict 标记,不展开 oldPath,拒绝目录、缺失端点、隐式 scheduled 祖先及路径别名跨根;cleanup 按 cleanup 能力只在工作区恰为完整、唯一且无歧义的整个根时允许,参数为空并确认整根元数据风险。两者复用一次性状态凭据、真实协商、安装审批、最终 preflight 与 Core ownerOf;确定 Provider 失败和未知结果经过停止/清理重试仍保留原分类,重试不重发。move、缺失端点、嵌套文件 add 与 gutter/Annotate 仍 pending,ui:3 与 vcsProviders 保持关闭;真实 Core stdio 夹具已通过专用计时入口显式运行(706ms),生产插件包、系统 SVN 客户端、原生进程树、完整包和人工界面验收仍 pending。", + "lastReviewed": "2026-10-29" } diff --git a/windows/tauri/crates/project/src/lib.rs b/windows/tauri/crates/project/src/lib.rs index 3f886c3722..25e01abdb5 100644 --- a/windows/tauri/crates/project/src/lib.rs +++ b/windows/tauri/crates/project/src/lib.rs @@ -62,6 +62,12 @@ impl FileWatcher { self.watch_path_with_mode(path, true, true) } + /// Metadata owners can observe an enclosing directory without recursively + /// claiming the user's entire parent working copy. + pub fn watch_directory_shallow(&self, path: String) -> Result<()> { + self.watch_path_with_mode(path, false, false) + } + fn watch_path_with_mode(&self, path: String, recursive: bool, emit_opened: bool) -> Result<()> { let path_buf = PathBuf::from(&path); diff --git a/windows/tauri/package.json b/windows/tauri/package.json index c1089d707f..f5c147bf27 100644 --- a/windows/tauri/package.json +++ b/windows/tauri/package.json @@ -143,5 +143,5 @@ "engines": { "node": ">=22.0.0" }, - "packageManager": "bun@1.3.12" + "packageManager": "bun@1.3.14" } diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index cc7a4db7d7..5e9190d517 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -991,6 +991,33 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling" version = "0.23.0" @@ -1316,6 +1343,30 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.18.0" @@ -1467,6 +1518,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -2998,6 +3055,7 @@ version = "0.3.0" dependencies = [ "base64 0.22.1", "chrono", + "ed25519-dalek", "http", "keyring", "lithe-agent-host", diff --git a/windows/tauri/src-tauri/Cargo.toml b/windows/tauri/src-tauri/Cargo.toml index bfb42251bb..9a284f628e 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ tauri-build = { version = "2", features = [] } [dependencies] base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } +ed25519-dalek = "2" lithe-agent-host = { path = "../../../rust/lithe-agent-host" } lithe-git-host = { path = "../../../rust/lithe-git-host" } lithe-core = { path = "../../../rust/lithe-core" } diff --git a/windows/tauri/src-tauri/src/core.rs b/windows/tauri/src-tauri/src/core.rs index bfb12c0caa..1177e245af 100644 --- a/windows/tauri/src-tauri/src/core.rs +++ b/windows/tauri/src-tauri/src/core.rs @@ -1,19 +1,77 @@ #[tauri::command] -pub async fn core_execute(request: String) -> String { - tauri::async_runtime::spawn_blocking(move || lithe_core::execute_json(&request)) - .await - .unwrap_or_else(|error| { - serde_json::json!({ - "id": null, - "ok": false, - "error": { - "code": "unknown", - "message": "The shared core operation could not complete", - "details": error.to_string() +pub async fn core_execute( + app: tauri::AppHandle, + window: tauri::WebviewWindow, + request: String, +) -> String { + let window_label = window.label().to_owned(); + tauri::async_runtime::spawn_blocking(move || { + let parsed = serde_json::from_str::(&request).ok(); + if let Some(parsed) = &parsed { + if crate::plugin_host::vcs::private_core_request(&app, parsed["command"].as_str().unwrap_or_default(), &parsed["payload"]) { + return serde_json::json!({"id":parsed["id"],"ok":false,"error":{ + "code":"invalid_request","message":"VCS request requires its native owner","details":"vcs.provider.unavailable" + }}).to_string(); + } + if let Some(result) = crate::plugin_host::provider::core_preflight( + &app, + &window_label, + parsed["command"].as_str().unwrap_or_default(), + &parsed["payload"], + ) { + return match result { + Ok(data) => { + serde_json::json!({"id":parsed["id"],"ok":true,"data":data}).to_string() + } + Err(error) => serde_json::json!({"id":parsed["id"],"ok":false,"error":{ + "code":"invalid_request","message":error.detail,"details":error.code, + }}) + .to_string(), + }; + } + if parsed["command"] == "lsp.startServer" { + if let Some(result) = crate::plugin_host::provider::language_start( + &app, + &window_label, + &parsed["payload"], + ) { + return match result { + Ok(data) => { + serde_json::json!({"id":parsed["id"],"ok":true,"data":data}).to_string() + } + Err(error) => serde_json::json!({"id":parsed["id"],"ok":false,"error":{ + "code":"process_failed","message":error.detail,"details":error.code, + }}) + .to_string(), + }; } - }) - .to_string() + } + } + let response = lithe_core::execute_json(&request); + if let (Some(parsed), Ok(response_value)) = (parsed, serde_json::from_str(&response)) { + crate::plugin_host::provider::observe_core( + &app, + &window_label, + parsed["command"].as_str().unwrap_or_default(), + &parsed["payload"], + &response_value, + ); + } + response + }) + .await + .unwrap_or_else(|error| { + serde_json::json!({ + "id": null, + "ok": false, + "error": { + "code": "unknown", + "message": "The shared core operation could not complete", + "details": error.to_string() + } }) + .to_string() + }) } #[tauri::command] diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 446ed06df8..53ca56717d 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -142,10 +142,17 @@ fn main() { app.manage(project_windows::ProjectWindows::default()); app.manage(window_title::WindowTitles::default()); app.manage(run::RunProcessManager::default()); + app.manage(plugin_host::run_plans::ExecutionState::default()); app.manage(debug::DebugAdapterManager::default()); app.manage(plugin_host::PluginHostState::default()); + app.manage(plugin_host::project_generators::GeneratorState::default()); + app.manage(plugin_host::vcs::VcsHostState::default()); + app.manage(plugin_host::provider::ProviderHostState::default()); + app.manage(plugin_host::functions::FunctionsState::default()); + app.manage(plugin_host::build::BuildHostState::default()); app.manage(plugin_host::web::WebPageHostState::default()); app.manage(plugin_host::lifecycle::LifecycleState::default()); + app.manage(plugin_host::marketplace::MarketplaceState::default()); run::cleanup_legacy_appdata(app.handle()); maven::clear_dependency_outputs(app.handle()); plugin_host::install::cleanup_stale_snapshots(app.handle()); @@ -159,8 +166,26 @@ fn main() { }) .on_window_event(|window, event| { if matches!(event, tauri::WindowEvent::Destroyed) { + plugin_host::run_plans::window_destroyed(window.app_handle(), window.label()); + plugin_host::project_generators::window_destroyed(window.app_handle(), window.label()); plugin_host::web::forget_window_appearance(window.app_handle(), window.label()); plugin_host::lifecycle::window_destroyed(window.app_handle(), window.label()); + plugin_host::vcs::window_destroyed(window.app_handle(), window.label()); + plugin_host::marketplace::window_destroyed(window.app_handle(), window.label()); + plugin_host::functions::window_destroyed(window); + let build_app = window.app_handle().clone(); + let build_window = window.label().to_owned(); + tauri::async_runtime::spawn(async move { + if let Err(error) = plugin_host::project_generators::close_window(&build_app, &build_window).await { + eprintln!("Could not release plugin project generation: {}", error.code); + } + if let Err(error) = plugin_host::provider::close_window(&build_app, &build_window).await { + eprintln!("Could not release plugin Provider sessions: {}", error.detail); + } + if let Err(error) = plugin_host::build::close_build_window(build_app, build_window).await { + eprintln!("Could not release plugin build operations: {error}"); + } + }); run::release_window_processes(window.label()); core::close_ide_hosts(window.label()); agent::close_window_connections(window.label()); @@ -299,25 +324,82 @@ fn main() { plugin_host::lifecycle::declarative_plugins_lifecycle_ack, plugin_host::declarative_plugins_rescan, plugin_host::declarative_plugins_state, + plugin_host::details::declarative_plugins_details, plugin_host::web::declarative_plugins_web_open_file_ack, + plugin_host::web::build::declarative_plugins_web_build_handle, + plugin_host::web::build::workflow::declarative_plugins_web_build_action, plugin_host::web::declarative_plugins_web_resolve_file, plugin_host::web::declarative_plugins_web_update_appearance, plugin_host::declarative_plugins_manifest, plugin_host::launch::declarative_plugins_language_launch, plugin_host::launch::declarative_plugins_language_owner, plugin_host::install::declarative_plugins_prepare_disk_install, + plugin_host::marketplace::declarative_marketplace_list, + plugin_host::marketplace::declarative_marketplace_refresh, + plugin_host::marketplace::declarative_marketplace_prepare, + plugin_host::marketplace::declarative_marketplace_confirm, + plugin_host::marketplace::declarative_marketplace_cancel, plugin_host::install::declarative_plugins_confirm_disk_install, plugin_host::install::declarative_plugins_cancel_disk_install, plugin_host::install::declarative_plugins_uninstall, plugin_host::install::declarative_plugins_set_enabled, plugin_host::install::declarative_plugins_rollback, plugin_host::settings::declarative_plugins_resolve_settings, + plugin_host::settings::declarative_plugins_setting_snapshot, + plugin_host::functions::declarative_plugins_function_prepare, + plugin_host::functions::declarative_plugins_function_confirm, + plugin_host::functions::declarative_plugins_function_state, + plugin_host::functions::declarative_plugins_function_cancel, + plugin_host::functions::declarative_plugins_web_function_prepare, + plugin_host::functions::native::declarative_plugins_native_function_open, + plugin_host::functions::native::declarative_plugins_native_function_prepare, + plugin_host::functions::native::declarative_plugins_native_function_close, plugin_host::settings::declarative_plugins_store_setting, + plugin_host::settings::declarative_plugins_reload_settings, + plugin_host::settings::reload::declarative_plugins_prepare_settings_reload_model, plugin_host::settings::declarative_plugins_workspace_configuration, + plugin_host::marketplace_sources::declarative_marketplace_sources_state, + plugin_host::marketplace_sources::declarative_marketplace_sources_add, + plugin_host::marketplace_sources::declarative_marketplace_sources_set_enabled, + plugin_host::marketplace_sources::declarative_marketplace_sources_remove, plugin_host::run_plans::declarative_plugins_run_plan, plugin_host::run_plans::declarative_plugins_test_plan, + plugin_host::run_plans::declarative_plugins_discover_tests, + plugin_host::run_plans::declarative_plugins_start_execution, + plugin_host::run_plans::declarative_plugins_execution_context, + plugin_host::run_plans::declarative_plugins_close_execution_workspace, + plugin_host::build::declarative_plugins_build_catalog, + plugin_host::project_generators::declarative_plugins_generator_catalog, + plugin_host::project_generators::declarative_plugins_generator_form, + plugin_host::project_generators::declarative_plugins_generator_runtime, + plugin_host::project_generators::declarative_plugins_generator_prepare, + plugin_host::project_generators::declarative_plugins_generator_generate, + plugin_host::project_generators::declarative_plugins_generator_cancel, + plugin_host::vcs::declarative_plugins_vcs_detect, + plugin_host::vcs::declarative_plugins_vcs_owners, + plugin_host::vcs::mappings::declarative_plugins_vcs_save_mapping, + plugin_host::vcs::declarative_plugins_vcs_close, + plugin_host::vcs::status::declarative_plugins_vcs_status, + plugin_host::vcs::query::declarative_plugins_vcs_query, + plugin_host::vcs::actions::declarative_plugins_vcs_write, + plugin_host::vcs::status::declarative_plugins_vcs_cancel_status, + plugin_host::build::declarative_plugins_build_language_import, + plugin_host::build::declarative_plugins_build_refresh, + plugin_host::build::declarative_plugins_build_set_option, + plugin_host::build::declarative_plugins_build_sync_check, + plugin_host::build::declarative_plugins_build_plugins_changed, + plugin_host::build::declarative_plugins_build_close_workspace, + plugin_host::build::declarative_plugins_build_prepare_model, + plugin_host::build::declarative_plugins_build_load_model, + plugin_host::build::declarative_plugins_build_authorize_provider_language, + plugin_host::build::declarative_plugins_build_cancel_operation, + plugin_host::build::declarative_plugins_build_prepare_run, + plugin_host::build::declarative_plugins_build_prepare_dependencies, + plugin_host::build::declarative_plugins_build_load_dependencies, + plugin_host::build::declarative_plugins_build_execute_run, plugin_host::web::declarative_plugins_close_workspace_pages, plugin_host::web::declarative_plugins_web_windows, + plugin_host::native_ui::declarative_plugins_native_windows, plugin_host::web::declarative_plugins_open_web_window, plugin_host::web::declarative_plugins_close_web_window, extension_store_placeholder::install_extension_from_url, @@ -335,6 +417,11 @@ fn main() { .expect("error while building Lithe desktop shell"); application.run(|app, event| { + if let tauri::RunEvent::ExitRequested { api, code, .. } = &event { + if plugin_host::project_generators::defer_exit(app, *code) { + api.prevent_exit(); + } + } if matches!(event, tauri::RunEvent::Exit) { owned_processes::shutdown(); if let Some(manager) = app.try_state::>() { diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index d00058897e..45f7e08a47 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -2087,8 +2087,15 @@ mod tests { } fn call(&self, command: &str, mut payload: serde_json::Value) -> serde_json::Value { payload["root"] = serde_json::json!(self.0); + // Initialization also inspects identity/configuration through several + // real Git subprocesses; the Windows case retains a 30s watchdog. + let timeout_milliseconds = if command == "git.initialize" { + 15_000 + } else { + 5000 + }; let response: serde_json::Value = serde_json::from_str(&lithe_core::execute_json(&serde_json::json!({ - "id": "tag-policy", "command": command, "timeoutMilliseconds": 5000, "payload": payload + "id": "tag-policy", "command": command, "timeoutMilliseconds": timeout_milliseconds, "payload": payload }).to_string())).unwrap(); assert_eq!(response["ok"], true, "{response}"); response["data"].clone() diff --git a/windows/tauri/src-tauri/src/plugin_host/build.rs b/windows/tauri/src-tauri/src/plugin_host/build.rs new file mode 100644 index 0000000000..6b9fc5e827 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build.rs @@ -0,0 +1,882 @@ +//! Tauri composition for declarative build detection and explicit model loads. +//! +//! Detection, static models, options and fingerprints remain passive. +//! Command and Provider models run after a window confirms a prepared operation. +//! The host still does not declare the +//! `buildSystems` capability, so no user-visible entry exists until the +//! remaining M4 batches land. Package trust is re-verified through M1's +//! launch gate (`verified_package_by_id`) on every call, the caller's window +//! label comes from the injected Tauri window (never from the frontend), and +//! the blocking Core/filesystem work runs on the async runtime instead of +//! the UI thread. + +use super::build_registry::{ + BuildCoreExecutor, BuildOptionsPersistence, BuildPackageInput, BuildPackageSource, + BuildRefreshTicket, BuildRegistry, StoredBuildOptions, +}; +use super::store; +use serde::Serialize; +use serde_json::Value; +use std::sync::Mutex; +use tauri::{AppHandle, Manager, WebviewWindow}; + +mod dependencies; +#[cfg(test)] +mod dependencies_tests; +mod host; +pub(super) mod host_integration; +#[cfg(test)] +mod host_integration_tests; +mod language_import; +#[cfg(test)] +mod language_import_tests; +mod operation; +mod output; +#[path = "web_build_native.rs"] +pub(super) mod page; +mod process; +mod provider; +#[cfg(test)] +mod provider_tests; +mod run; +#[cfg(test)] +mod run_tests; +#[cfg(test)] +mod tests; + +pub use dependencies::{ + BuildDependenciesResult, BuildDependenciesReview, BuildDependenciesSelection, +}; +pub use host::BuildModelLoadResult; +pub use language_import::BuildLanguageImport; +pub use operation::BuildModelReview; +pub use output::BuildRunTerminal; +pub use run::{BuildRunReview, BuildRunSelection}; + +/// Registry shared by every window; entries are keyed per window label. +#[derive(Default)] +pub struct BuildHostState { + registry: Mutex, + operations: operation::BuildOperations, +} + +impl BuildHostState { + /// VCS owns its own workspace admission and does not require a detected + /// build model. It shares only settings-reload invalidation with builds. + pub(crate) fn settings_scope_revision( + &self, + window: &str, + root: &str, + plugin: &str, + ) -> Result { + self.registry + .try_lock() + .map_err(|_| "plugin.settings.staleContext")? + .settings_scope_revision(window, root, plugin) + } + + /// Bind function preparation to one plugin's settings incarnation without + /// invalidating other plugins or another window's workspace. + pub(crate) fn function_scope_revision( + &self, + window: &str, + root: &str, + plugin: &str, + ) -> Result { + self.registry + .try_lock() + .map_err(|_| "plugin.function.staleContext")? + .function_scope_revision(window, root, plugin) + } + + /// Function consumers retain the same detected instance, including its + /// option revision; they cannot grant themselves a workspace by IPC. + pub(crate) fn function_context_is_current( + &self, + context: &super::build_registry::BuildModelContext, + ) -> bool { + self.registry + .lock() + .is_ok_and(|registry| registry.context_is_current(context)) + } + /// Read the workspace incarnation already registered by the native build + /// router. A settings action cannot create a scope by supplying a path. + pub(crate) fn function_generation(&self, window: &str, root: &str) -> Result { + let registry = self + .registry + .lock() + .map_err(|_| "plugin.function.staleContext")?; + registry.function_generation(window, root) + } +} + +/// Executes Core through the shared host envelope. +pub(super) struct HostCoreExecutor; + +impl BuildCoreExecutor for HostCoreExecutor { + fn execute(&self, command: &str, payload: Value) -> Result { + super::scan::core_execute(command, payload) + } +} + +/// Every build-visible package passes M1's production trust gate: accepted +/// in the scan, enabled, not lifecycle-blocked, and re-verified against the +/// live bytes (`verified_package_by_id`). While `hostCapabilities` keeps +/// `buildSystems` closed, Core's validate gate rejects such packages, so +/// this stays empty in production; tests inject fixture manifests instead. +fn verified_build_packages(app: &AppHandle) -> Vec { + let mut packages = Vec::new(); + for candidate in app.state::().packages() { + if candidate.entry.status != "accepted" + || !candidate.entry.enabled + || app + .state::() + .blocked(&candidate.entry.plugin_id) + { + continue; + } + // Re-run the launch trust check per package; a quarantined or + // tampered package is skipped here instead of surfacing later. + if let Ok(package) = super::launch::verified_package_by_id(app, &candidate.entry.plugin_id) + { + if package.manifest["buildSystems"] + .as_array() + .is_some_and(|systems| !systems.is_empty()) + { + packages.push(package); + } + } + } + packages +} + +fn package_input(package: &super::ScannedPackage) -> Option { + Some(BuildPackageInput { + plugin_id: package.entry.plugin_id.clone(), + version: package.entry.version.clone(), + manifest: package.manifest.clone(), + content_identity: format!( + "{}:{}", + package.root.canonicalize().ok()?.display(), + super::signature::content_digest(&package.root).ok()? + ), + }) +} + +/// The verified build package source used by the registry. +struct HostPackageSource { + app: AppHandle, + /// A registry call keeps the package mutation barrier through model read, + /// final identity checks and publication. No process or writable resource + /// is acquired by a passive model. + leases: Mutex>, +} + +impl BuildPackageSource for HostPackageSource { + fn accepted_packages(&self) -> Vec { + self.app + .state::() + .packages() + .into_iter() + .filter(|package| package.entry.status == "accepted" && package.entry.enabled) + .filter_map(|package| self.package(&package.entry.plugin_id)) + .collect() + } + + fn package(&self, plugin_id: &str) -> Option { + { + let mut leases = self.leases.lock().ok()?; + if !leases.contains_key(plugin_id) { + let lease = self + .app + .state::() + .acquire(plugin_id) + .ok()?; + leases.insert(plugin_id.into(), lease); + } + } + let package = super::launch::verified_package_by_id(&self.app, plugin_id).ok()?; + if !package.manifest["buildSystems"] + .as_array() + .is_some_and(|systems| !systems.is_empty()) + { + return None; + } + package_input(&package) + } +} + +/// Build option values per workspace under +/// `app_local_data_dir()/plugin-build-options/.json`, beside +/// the other plugin state; nothing is written into packages or the install +/// directory. +struct HostOptionsPersistence { + app: AppHandle, +} + +impl HostOptionsPersistence { + fn store_path(&self, workspace_root: &str) -> Result { + Ok(self + .app + .path() + .app_local_data_dir() + .map_err(|error| error.to_string())? + .join("plugin-build-options") + .join(format!( + "{}.json", + store::workspace_state_key(workspace_root) + ))) + } +} + +impl BuildOptionsPersistence for HostOptionsPersistence { + fn load(&self, workspace_root: &str) -> Result { + let path = self.store_path(workspace_root)?; + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(StoredBuildOptions::default()) + } + Err(error) => return Err(error.to_string()), + }; + serde_json::from_str(&text) + .map_err(|error| format!("{} is not valid JSON: {error}", path.display())) + } + + fn save(&self, workspace_root: &str, options: &StoredBuildOptions) -> Result<(), String> { + let path = self.store_path(workspace_root)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let temp = path.with_extension("json.tmp"); + std::fs::write( + &temp, + serde_json::to_string_pretty(options).map_err(|e| e.to_string())?, + ) + .map_err(|error| error.to_string())?; + std::fs::rename(&temp, &path).map_err(|error| error.to_string()) + } +} + +/// One plugin's declared build systems, for tool-window registration only. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildCatalogEntry { + pub plugin_id: String, + pub plugin_display_name: String, + pub build_system_id: String, + pub build_system_display_name: String, + /// Presentation icon path as declared; the host does not interpret it. + pub icon: Option, +} + +/// Declared build systems of packages that pass the trust gate right now. +/// This is a structural listing for window registration; Core validates +/// every declaration again on the next `build.detect`. +#[tauri::command] +pub async fn declarative_plugins_build_catalog( + app: AppHandle, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let mut entries = Vec::new(); + for package in verified_build_packages(&app) { + for system in package.manifest["buildSystems"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + { + let id = system["id"].as_str().unwrap_or_default(); + if id.is_empty() { + continue; + } + entries.push(BuildCatalogEntry { + plugin_id: package.entry.plugin_id.clone(), + plugin_display_name: package.entry.display_name.clone(), + build_system_id: id.to_string(), + build_system_display_name: system["displayName"] + .as_str() + .unwrap_or(id) + .to_string(), + icon: system["icon"].as_str().map(str::to_string), + }); + } + } + Ok(entries) + }) + .await + .map_err(|error| format!("build catalog task failed: {error}"))? +} + +/// Passive Core import for one exact Java/build snapshot. No project code or +/// Wrapper is executed; the optional identity rechecks a prepared snapshot. +#[tauri::command] +pub async fn declarative_plugins_build_language_import( + app: AppHandle, + window: WebviewWindow, + workspace_root: String, + generation: u64, + source_path: Option, + expected_identity: Option, +) -> Result { + language_import::prepare( + app, + window.label().into(), + workspace_root, + generation, + source_path, + expected_identity, + ) + .await +} + +/// Re-detects build-system instances for the calling window and workspace. +/// The window label comes from the injected window, never the frontend. +#[tauri::command] +pub async fn declarative_plugins_build_refresh( + app: AppHandle, + window: WebviewWindow, + workspace_root: String, +) -> Result { + let window_label = window.label().to_string(); + let (_suspension, ticket) = suspend_build_refresh(&app, &window_label, &workspace_root)?; + stop_build_operations(&app, |operation| { + operation.context.window_label == window_label + }) + .await?; + let checkpoint_app = app.clone(); + run_build_task(app, move |registry, source, persistence| { + let view = + registry.refresh_reserved(&HostCoreExecutor, source, persistence, &ticket, &|| { + live_build_window(&checkpoint_app, &window_label) + })?; + serde_json::to_value(view).map_err(|error| error.to_string()) + }) + .await +} + +/// Applies one option edit (or reset with `value: null`). The candidate is +/// validated through Core before anything is persisted. +#[tauri::command] +pub async fn declarative_plugins_build_set_option( + app: AppHandle, + window: WebviewWindow, + workspace_root: String, + plugin_id: String, + build_system_id: String, + root: String, + option_id: String, + value: Option, +) -> Result { + let window_label = window.label().to_string(); + run_build_option_edit( + app, + window_label.clone(), + move |registry, source, persistence| { + let view = registry.set_option( + &HostCoreExecutor, + source, + persistence, + &window_label, + &workspace_root, + &plugin_id, + &build_system_id, + &root, + &option_id, + value, + )?; + serde_json::to_value(view).map_err(|error| error.to_string()) + }, + ) + .await +} + +/// Recomputes one instance's sync fingerprint; the frontend debounces this +/// on workspace file changes. +#[tauri::command] +pub async fn declarative_plugins_build_sync_check( + app: AppHandle, + window: WebviewWindow, + workspace_root: String, + plugin_id: String, + build_system_id: String, + root: String, +) -> Result { + let window_label = window.label().to_string(); + run_build_task(app, move |registry, source, persistence| { + let view = registry.sync_check( + &HostCoreExecutor, + source, + &window_label, + &workspace_root, + &plugin_id, + &build_system_id, + &root, + )?; + serde_json::to_value(view).map_err(|error| error.to_string()) + }) + .await +} + +/// Revokes every session of one plugin across all windows. Called from the +/// frontend lifecycle listener when a plugin is disabled, replaced, rolled +/// back, or uninstalled, so stored sessions can never answer again. +#[tauri::command] +pub async fn declarative_plugins_build_plugins_changed( + app: AppHandle, + window: WebviewWindow, + plugin_id: String, +) -> Result<(), String> { + let window_label = window.label().to_string(); + app.state::() + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")? + .drop_plugin(&plugin_id); + stop_build_operations(&app, |operation| operation.context.plugin_id == plugin_id).await?; + run_build_task(app, move |registry, _source, _persistence| { + registry.drop_plugin(&plugin_id); + let _ = window_label; + Ok(()) + }) + .await +} + +/// Clears the calling window's sessions when its workspace closed, so +/// reopening the same path cannot reuse the pre-close sessions. +#[tauri::command] +pub async fn declarative_plugins_build_close_workspace( + app: AppHandle, + window: WebviewWindow, + workspace_root: String, +) -> Result<(), String> { + let window_label = window.label().to_string(); + super::provider::close_workspace(&app, &window_label, &workspace_root) + .await + .map_err(|error| error.detail)?; + // A late close for a detached workspace must not suspend a newer one. + let _suspension = { + let state = app.state::(); + let mut registry = state + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")?; + if registry.view(&window_label).workspace_root == workspace_root { + let generation = registry.suspend_execution(&window_label); + Some(BuildExecutionSuspension { + app: app.clone(), + window: window_label.clone(), + generation, + }) + } else { + None + } + }; + super::functions::close_workspace(&app, &window_label, &workspace_root).await?; + stop_build_operations(&app, |operation| { + operation.context.window_label == window_label + && operation.context.workspace_root == workspace_root + }) + .await?; + run_build_task(app, move |registry, _source, _persistence| { + registry.close_workspace(&window_label, &workspace_root); + Ok(()) + }) + .await +} + +#[tauri::command] +pub async fn declarative_plugins_build_prepare_model( + app: AppHandle, + window: WebviewWindow, + workspace_root: String, + plugin_id: String, + build_system_id: String, + root: String, + runtime_executables: std::collections::BTreeMap, +) -> Result { + host::prepare( + app, + window.label().to_string(), + workspace_root, + plugin_id, + build_system_id, + root, + runtime_executables, + ) + .await +} + +#[tauri::command] +pub async fn declarative_plugins_build_load_model( + app: AppHandle, + window: WebviewWindow, + operation_id: String, +) -> Result { + let result = host::load(app.clone(), window.label().to_string(), operation_id).await?; + run_build_task(app, |_, _, _| Ok(())).await?; + Ok(result) +} + +/// Confirms only the reviewed shared-language start. The following model or +/// dependency command still claims and verifies the same native operation. +#[tauri::command] +pub async fn declarative_plugins_build_authorize_provider_language( + app: AppHandle, + window: WebviewWindow, + operation_id: String, +) -> Result<(), String> { + provider::authorize_shared(app, window.label().to_owned(), operation_id).await +} + +#[tauri::command] +pub async fn declarative_plugins_build_cancel_operation( + app: AppHandle, + window: WebviewWindow, + operation_id: String, +) -> Result<(), String> { + host::cancel(app, window.label().to_string(), operation_id).await +} + +/// Prepares a declared task/test against the current native model. This call +/// never probes a wrapper or starts a project process. +#[tauri::command] +pub async fn declarative_plugins_build_prepare_run( + app: AppHandle, + window: WebviewWindow, + workspace_root: String, + plugin_id: String, + build_system_id: String, + root: String, + selection: BuildRunSelection, +) -> Result { + host::prepare_run( + app, + window.label().to_string(), + workspace_root, + plugin_id, + build_system_id, + root, + selection, + ) + .await +} + +/// Confirms exactly one prepared operation. Subscribe to this window's +/// `declarative-build-output` before calling; terminal is the last event. +#[tauri::command] +pub async fn declarative_plugins_build_execute_run( + app: AppHandle, + window: WebviewWindow, + operation_id: String, +) -> Result { + host::execute_run(app, window.label().to_string(), operation_id).await +} + +/// Prepares Core dependency argv with native model IDs, without probing tools. +#[tauri::command] +pub async fn declarative_plugins_build_prepare_dependencies( + app: AppHandle, + window: WebviewWindow, + workspace_root: String, + plugin_id: String, + build_system_id: String, + root: String, + selection: BuildDependenciesSelection, +) -> Result { + dependencies::prepare( + app, + window.label().to_string(), + workspace_root, + plugin_id, + build_system_id, + root, + selection, + ) + .await +} + +/// Confirms the shared single-use operation; Core trees return after cleanup. +#[tauri::command] +pub async fn declarative_plugins_build_load_dependencies( + app: AppHandle, + window: WebviewWindow, + operation_id: String, +) -> Result { + dependencies::load(app, window.label().to_string(), operation_id).await +} + +async fn stop_build_operations( + app: &AppHandle, + predicate: impl Fn(&operation::ModelOperation) -> bool, +) -> Result<(), String> { + let operations = app + .state::() + .operations + .cancel_matching(predicate); + host::drain(app, operations).await +} + +/// Settings use the existing model owner and its retryable cleanup. No +/// package-wide lifecycle event or persistent enable-state change is needed. +pub(crate) async fn reload_settings_models( + app: AppHandle, + label: String, + receipt: super::settings::reload::Receipt, +) -> Result { + let preflight_app = app.clone(); + let preflight_label = label.clone(); + let preflight = receipt.clone(); + let (generation, operations) = tauri::async_runtime::spawn_blocking(move || { + super::settings::reload::check_owner(&preflight_app, &preflight_label, &preflight)?; + let state = preflight_app.state::(); + let mut registry = state + .registry + .lock() + .map_err(|_| "plugin.settings.unavailable")?; + // A failed drain still owns the original operations. Reclaim them + // before checking a later settings conflict, as the global barrier does. + if !registry.settings_reload_pending( + &preflight_label, + &preflight.workspace_path, + &preflight.plugin_id, + ) { + super::settings::reload::check(&preflight_app, &preflight_label, &preflight)?; + } + let generation = registry.begin_settings_reload( + &preflight_label, + &preflight.workspace_path, + &preflight.plugin_id, + )?; + // Capture original owners while admission is still locked. A delayed + // drain must never select a replacement workspace by its reused label. + let operations = state.operations.matching(|operation| { + operation.context.window_label == preflight_label + && operation.context.workspace_root == preflight.workspace_path + && operation.context.plugin_id == preflight.plugin_id + }); + Ok::<_, String>((generation, operations)) + }) + .await + .map_err(|_| "plugin.settings.unavailable")??; + for operation in &operations { + operation.cancel(); + } + let function_cleanup = + super::functions::close_scope(&app, &label, &receipt.workspace_path, &receipt.plugin_id) + .await; + let vcs_cleanup = + super::vcs::close_scope(&app, &label, &receipt.workspace_path, &receipt.plugin_id).await; + let build_cleanup = host::drain(&app, operations).await; + function_cleanup + .and(vcs_cleanup) + .and(build_cleanup) + .map_err(|error| format!("plugin.settings.reloadStopFailed: {error}"))?; + let checkpoint_app = app.clone(); + run_build_task(app, move |registry, packages, persistence| { + let checkpoint = + || super::settings::reload::check(&checkpoint_app, &label, &receipt).map(|_| ()); + let result = registry.reload_settings( + &HostCoreExecutor, + packages, + persistence, + &label, + &receipt.workspace_path, + generation, + &receipt.plugin_id, + &checkpoint, + ); + if result.as_ref().is_err_and(|error| { + error == "plugin.settings.conflict" || error == "plugin.settings.staleContext" + }) { + registry.finish_settings_reload(&label, generation, &receipt.plugin_id); + } + result + }) + .await +} + +/// Bind the ordinary explicit model preparation to the original saved receipt. +/// Its normal execution guard rechecks this binding before start/publication. +pub(crate) async fn prepare_settings_reload_model( + app: AppHandle, + label: String, + receipt: super::settings::reload::Receipt, + system: String, + root: String, + runtimes: std::collections::BTreeMap, +) -> Result { + let check_app = app.clone(); + let check_label = label.clone(); + let expected = receipt.clone(); + tauri::async_runtime::spawn_blocking(move || { + super::settings::reload::check(&check_app, &check_label, &expected) + }) + .await + .map_err(|_| "plugin.settings.unavailable")??; + let review = host::prepare_with_refresh( + app.clone(), + label.clone(), + receipt.workspace_path.clone(), + receipt.plugin_id.clone(), + system, + root, + runtimes, + true, + ) + .await?; + let operation = app + .state::() + .operations + .get(&label, &review.operation_id)?; + *operation + .settings_reload + .lock() + .map_err(|_| "plugin.settings.unavailable")? = Some(receipt); + Ok(review) +} + +struct BuildExecutionSuspension { + app: AppHandle, + window: String, + generation: u64, +} +impl Drop for BuildExecutionSuspension { + fn drop(&mut self) { + let state = self.app.state::(); + if let Ok(mut registry) = state.registry.lock() { + registry.resume_execution(&self.window); + }; + super::web::build::workflow::queue_instance_changes(self.app.clone()); + } +} + +fn suspend_build_execution( + app: &AppHandle, + window_label: &str, +) -> Result { + let generation = app + .state::() + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")? + .suspend_execution(window_label); + Ok(BuildExecutionSuspension { + app: app.clone(), + window: window_label.into(), + generation, + }) +} + +fn live_build_window(app: &AppHandle, window_label: &str) -> Result<(), String> { + if (window_label == "main" || window_label.starts_with("workspace-")) + && app.get_webview_window(window_label).is_some() + { + Ok(()) + } else { + Err("plugin.build.staleContext".into()) + } +} + +fn suspend_build_refresh( + app: &AppHandle, + window_label: &str, + workspace_root: &str, +) -> Result<(BuildExecutionSuspension, BuildRefreshTicket), String> { + live_build_window(app, window_label)?; + let state = app.state::(); + let mut registry = state + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")?; + let generation = registry.suspend_execution(window_label); + let ticket = registry.reserve_refresh(window_label, workspace_root); + Ok(( + BuildExecutionSuspension { + app: app.clone(), + window: window_label.into(), + generation, + }, + ticket, + )) +} + +/// Main's native window-destruction hook must call this even when pagehide +/// cannot run. Native cancellation also observes a missing host window. +pub async fn close_build_window(app: AppHandle, window_label: String) -> Result<(), String> { + let _suspension = suspend_build_execution(&app, &window_label)?; + stop_build_operations(&app, |operation| { + operation.context.window_label == window_label + }) + .await?; + app.state::() + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")? + .close_window(&window_label); + Ok(()) +} + +/// Runs one registry operation on the async runtime's blocking pool: Core +/// calls and file I/O must never occupy the UI thread, and the registry +/// mutex is only held inside the spawned task. +pub(super) async fn run_build_task(app: AppHandle, operation: F) -> Result +where + T: Send + 'static, + F: FnOnce( + &mut BuildRegistry, + &dyn BuildPackageSource, + &dyn BuildOptionsPersistence, + ) -> Result + + Send + + 'static, +{ + tauri::async_runtime::spawn_blocking(move || { + let source = HostPackageSource { + app: app.clone(), + leases: Mutex::new(Default::default()), + }; + let persistence = HostOptionsPersistence { app: app.clone() }; + let state = app.state::(); + let mut registry = state + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")?; + let result = operation(&mut registry, &source, &persistence); + if result.is_ok() { + super::web::build::workflow::publish_instance_changes(&app, ®istry, &source); + } + result + }) + .await + .map_err(|error| format!("build task failed: {error}"))? +} + +/// Web option edits share the native suspension and cleanup barrier. Their +/// page checkpoint runs inside the same registry task before any persistence. +pub(super) async fn run_build_option_edit( + app: AppHandle, + window_label: String, + operation: F, +) -> Result +where + T: Send + 'static, + F: FnOnce( + &mut BuildRegistry, + &dyn BuildPackageSource, + &dyn BuildOptionsPersistence, + ) -> Result + + Send + + 'static, +{ + let suspension = suspend_build_execution(&app, &window_label)?; + let generation = suspension.generation; + stop_build_operations(&app, |operation| { + operation.context.window_label == window_label + }) + .await?; + run_build_task(app, move |registry, source, persistence| { + registry.check_generation(&window_label, generation)?; + operation(registry, source, persistence) + }) + .await +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/dependencies.rs b/windows/tauri/src-tauri/src/plugin_host/build/dependencies.rs new file mode 100644 index 0000000000..6d232cb8ab --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/dependencies.rs @@ -0,0 +1,520 @@ +//! Explicit dependency loading through the existing native operation +//! owner. Core selects argv and decodes trees; the host owns consent, frozen +//! inputs, a fresh plugin-state output file, and guarded result publication. + +use super::super::build_registry::{BuildCoreExecutor, BuildModelContext}; +use super::super::build_resolver::WindowsBuildToolResolver; +use super::host::{verified_identity, HostGuard, WindowBuildOutput}; +use super::operation::*; +use super::output::{BuildOutputSink, BuildRunTerminal, RunEvents, Utf8Stream, MAX_RUN_OUTPUT}; +use super::process::{validate_output_file, ModelOutputFile, NativeBuildExecutor}; +use super::{store, BuildHostState, HostCoreExecutor}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Manager}; + +/// Native entity ID only: paths, programs, env and output destinations cannot +/// be supplied by the webview. None selects the exact build instance root. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BuildDependenciesSelection { + pub project_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildDependenciesReview { + #[serde(flatten)] + pub operation: BuildModelReview, + pub project_id: Option, + pub title: String, + pub kind: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildDependenciesResult { + #[serde(flatten)] + pub review: BuildDependenciesReview, + pub terminal: BuildRunTerminal, + /// None on failure/cancellation; an empty successful tree is Some([]). + pub dependencies: Option, + pub truncated: bool, + /// Complete, unchanged Core tree for consumers of the public web contract. + pub dependency_tree: Option, +} + +pub(super) struct DecodedDependencies { + pub nodes: Value, + pub truncated: bool, + pub tree: Value, +} + +pub(super) struct PreparedDependencies { + pub project_id: Option, + pub(super) project_path: Option, + pub provider_plan: Option, + pub title: String, + pub fingerprint: String, + pub events: RunEvents, +} + +/// Pure plan preparation. No output allocation, resolver advance or process +/// occurs until the operation is explicitly confirmed. +pub(super) fn plan( + core: &dyn BuildCoreExecutor, + context: &BuildModelContext, + selection: &BuildDependenciesSelection, + plugin_directory: &Path, + state_directory: &Path, + output_file: &Path, + id: &str, + fingerprint: String, + sink: Arc, +) -> Result<(ModelCommandPlan, PreparedDependencies), String> { + let system = context.package.manifest["buildSystems"] + .as_array() + .and_then(|systems| { + systems + .iter() + .find(|system| system["id"] == context.build_system_id) + }) + .ok_or("plugin.build.invalidSelection")?; + if !matches!( + system["dependencies"]["source"].as_str(), + Some("command" | "hostIntegration" | "provider") + ) { + return Err("plugin.build.unavailable".into()); + } + if selection.project_id.is_some() && (context.model.is_null() || context.model_stale) { + return Err("plugin.build.modelReloadRequired".into()); + } + let project = selection + .project_id + .as_ref() + .map(|id| { + context.model["projects"] + .as_array() + .and_then(|projects| { + projects + .iter() + .find(|project| project["id"].as_str() == Some(id.as_str())) + }) + .cloned() + .ok_or("plugin.build.invalidSelection") + }) + .transpose()?; + let response = core.execute("build.dependencyPlan", json!({ + "manifest":context.package.manifest, "buildSystemID":context.build_system_id, + "request":{"root":context.root,"options":context.options,"project":project, + "pluginDirectory":plugin_directory,"workspaceStateDirectory":state_directory,"outputFile":output_file}, + }))?; + core_ok(&response)?; + let provider_plan = response["plan"]["provider"] + .is_object() + .then(|| response["plan"].clone()); + if provider_plan.is_none() + && (response["plan"]["execution"] != "task" + || response["plan"]["workingDirectory"] != context.root) + { + return Err("plugin.build.invalidPlan".into()); + } + let mut command: ModelCommandPlan = if let Some(plan) = &provider_plan { + super::provider::command(plan)? + } else { + serde_json::from_value(response["plan"].clone()).map_err(|_| "plugin.build.invalidPlan")? + }; + if !matches!( + command.output.as_str(), + "stdout" | "outputFile" | "provider" + ) { + return Err("plugin.build.invalidPlan".into()); + } + // Dependency resolution can run project extensions and fetch artifacts. + // Keep review conservative even if a declaration understates these risks. + command.executes_project_code = true; + command.may_use_network = true; + Ok(( + command, + PreparedDependencies { + project_id: selection.project_id.clone(), + project_path: project + .as_ref() + .and_then(|project| project["path"].as_str()) + .map(str::to_string), + title: context.build_system_id.clone(), + fingerprint, + provider_plan, + events: RunEvents::new(context, id, sink), + }, + )) +} + +pub(super) fn review(operation: &ModelOperation) -> Result { + let dependencies = operation + .dependencies + .as_ref() + .ok_or("plugin.build.invalidPlan")?; + Ok(BuildDependenciesReview { + operation: operation.review(), + project_id: dependencies.project_id.clone(), + title: dependencies.title.clone(), + kind: "dependencies".into(), + }) +} + +/// A completed process returns data only after Core decoding. Cleanup failure +/// is propagated separately so the host can retain both lease and scratch file. +pub(super) fn execute( + operation: &ModelOperation, + core: &dyn BuildCoreExecutor, + executor: &dyn BuildProcessExecutor, + guard: &dyn ModelExecutionGuard, +) -> Result<(BuildRunTerminal, Option), String> { + let dependencies = operation + .dependencies + .as_ref() + .ok_or("plugin.build.invalidPlan")?; + guard.checkpoint()?; + let tool = resolve_tool(operation, core, executor, guard)?; + guard.checkpoint()?; + let mut stdout = Vec::new(); + let mut streams = [Utf8Stream::default(), Utf8Stream::default()]; + let mut bytes = 0usize; + let process = executor.run_command( + &BuildProcessPlan { + executable: tool.executable, + arguments: operation.plan.arguments.clone(), + environment: tool.environment, + directory: operation.working_directory.clone(), + timeout: Duration::from_secs(600), + output_limit: MODEL_OUTPUT_LIMIT, + output_file: (operation.plan.output == "outputFile") + .then(|| operation.output_file.clone()), + }, + guard, + &mut |stream, chunk| { + operation.publish(|| { + if guard.cancelled() { + return Err("plugin.build.cancelled".into()); + } + bytes = bytes.saturating_add(chunk.len()); + if bytes > MAX_RUN_OUTPUT { + return Err("plugin.build.outputLimit".into()); + } + let (index, name) = match stream { + lithe_git_host::Stream::Stdout => (0, "stdout"), + lithe_git_host::Stream::Stderr => (1, "stderr"), + }; + if index == 0 && operation.plan.output == "stdout" { + stdout.extend_from_slice(chunk); + } + dependencies.events.chunk(name, &streams[index].push(chunk)) + }) + }, + ); + if process + .failure + .as_deref() + .is_some_and(|error| error.starts_with("plugin.build.cleanupFailed")) + { + return Err(process.failure.unwrap()); + } + let mut terminal = BuildRunTerminal { + kind: if !process.started { + "failedToStart" + } else if process.failure.is_some() || process.exit_code != Some(0) { + "failed" + } else { + "succeeded" + } + .into(), + exit_code: process.exit_code, + message: process.failure, + }; + let decoded = (|| -> Result, String> { + guard.checkpoint()?; + for (index, stream) in ["stdout", "stderr"].into_iter().enumerate() { + let tail = streams[index].finish(); + operation.publish(|| { + guard.checkpoint()?; + dependencies.events.chunk(stream, &tail) + })?; + } + if terminal.kind != "succeeded" { + return Ok(None); + } + if operation.plan.output == "outputFile" { + validate_output_file(&operation.output_file)?; + } + let output = String::from_utf8(stdout).map_err(|_| "plugin.build.invalidOutput")?; + let response = core.execute("build.decodeDependencies", json!({ + "manifest":operation.context.package.manifest,"buildSystemID":operation.context.build_system_id, + "request":{"workspaceRoot":operation.context.workspace_root,"root":operation.context.root, + "project":dependencies.project_path,"output":(operation.plan.output == "stdout").then_some(output), + "outputFile":(operation.plan.output == "outputFile").then(|| operation.output_file.to_string_lossy().into_owned())}, + }))?; + core_ok(&response)?; + guard.checkpoint()?; + // The command envelope contains a decoded result with its project, + // dependency nodes and truncation flag, rather than a bare tree. + let decoded = &response["dependencies"]; + if !decoded["dependencies"].is_array() { + return Err("plugin.build.invalidOutput".into()); + } + Ok(Some(DecodedDependencies { + nodes: decoded["dependencies"].clone(), + truncated: decoded["truncated"] == true, + tree: decoded.clone(), + })) + })(); + match decoded { + Ok(tree) => Ok((terminal, tree)), + Err(error) => { + terminal.kind = if super::run::error_terminal(&error).kind == "cancelled" { + "cancelled" + } else if process.started { + "failed" + } else { + "failedToStart" + } + .into(); + terminal.message = Some(error); + Ok((terminal, None)) + } + } +} + +pub(super) struct DependencyGuard<'a> { + pub base: HostGuard<'a>, +} +impl ModelExecutionGuard for DependencyGuard<'_> { + fn checkpoint(&self) -> Result<(), String> { + self.base.checkpoint()?; + let state = self.base.app.state::(); + let registry = state + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")?; + if registry.model_fingerprint(&HostCoreExecutor, &self.base.operation.context)? + != self + .base + .operation + .dependencies + .as_ref() + .ok_or("plugin.build.invalidPlan")? + .fingerprint + { + return Err("plugin.build.staleContext".into()); + } + Ok(()) + } + fn cancelled(&self) -> bool { + self.base.cancelled() + } + fn start_permit(&self) -> Result, String> { + self.checkpoint()?; + self.base.start_permit() + } +} + +pub(super) async fn prepare( + app: AppHandle, + window_label: String, + workspace_root: String, + plugin_id: String, + build_system_id: String, + root: String, + selection: BuildDependenciesSelection, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if window_label != "main" && !window_label.starts_with("workspace-") { + return Err("plugin.build.staleContext".into()); + } + let state = app.state::(); + let (context, fingerprint) = { + let registry = state + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")?; + let context = registry.model_context( + &window_label, + &workspace_root, + &plugin_id, + &build_system_id, + &root, + )?; + let fingerprint = registry.model_fingerprint(&HostCoreExecutor, &context)?; + // A direct IPC caller cannot bypass the frontend's passive sync. + // Model IDs must still describe the on-disk inputs, including a + // POM edit whose watcher notification has not been delivered yet. + if selection.project_id.is_some() + && !registry + .view(&window_label) + .instances + .iter() + .any(|instance| { + instance["pluginID"] == plugin_id + && instance["buildSystemID"] == build_system_id + && instance["root"] == root + && instance["syncDigest"].as_str() == Some(fingerprint.as_str()) + }) + { + return Err("plugin.build.modelReloadRequired".into()); + } + (context, fingerprint) + }; + let _lease = app + .state::() + .acquire(&plugin_id)?; + let package = verified_identity(&app, &context)?; + let id = next_operation_id(); + let state_directory = store::plugin_state_directory(&app, &plugin_id)?; + let output_file = state_directory.join(format!("{id}.dependencies.json")); + let (command, dependencies) = plan( + &HostCoreExecutor, + &context, + &selection, + &package.root, + &state_directory.join(store::workspace_state_key(&workspace_root)), + &output_file, + &id, + fingerprint, + Arc::new(WindowBuildOutput(app.clone())), + )?; + let settings = super::super::settings::load_layers(&app, &plugin_id, &workspace_root)?; + let invocation = HostCoreExecutor.execute( + "build.invocationPlan", + json!({ + "manifest":context.package.manifest,"buildSystemID":build_system_id, + "request":{"root":root,"platform":"windows","settings":settings.global}, + }), + )?; + core_ok(&invocation)?; + let working_directory = WindowsBuildToolResolver::instance_directory( + &invocation["invocation"], + Path::new(&workspace_root), + ) + .map_err(|error| format!("{}: {error}", error.code()))?; + let resolver = WindowsBuildToolResolver::from_current_process(BTreeMap::new()) + .freeze_discovery() + .map_err(|error| error.to_string())?; + let prepared_provider = dependencies + .provider_plan + .clone() + .map(|plan| super::provider::prepare(&app, &context, plan, BTreeMap::new())) + .transpose()?; + let mut operation = ModelOperation::new( + id.clone(), + context, + command, + invocation["invocation"].clone(), + resolver, + output_file, + working_directory, + ); + operation.dependencies = Some(dependencies); + operation.provider = prepared_provider; + let guard = DependencyGuard { + base: HostGuard { + app: &app, + operation: &operation, + deadline: Instant::now() + Duration::from_secs(660), + }, + }; + guard.checkpoint()?; + let result = review(&operation)?; + state.operations.insert(operation)?; + let operation = state.operations.get(&window_label, &id)?; + let guard = DependencyGuard { + base: HostGuard { + app: &app, + operation: &operation, + deadline: Instant::now() + Duration::from_secs(660), + }, + }; + if let Err(error) = guard.checkpoint() { + operation.cancel(); + return Err(error); + } + Ok(result) + }) + .await + .map_err(|_| "plugin.build.processFailed".to_string())? +} + +pub(super) async fn load( + app: AppHandle, + window_label: String, + operation_id: String, +) -> Result { + let operation = app + .state::() + .operations + .get(&window_label, &operation_id)?; + if operation.dependencies.is_none() { + return Err("plugin.build.invalidPlan".into()); + } + let running = app + .state::() + .operations + .claim(&window_label, &operation_id)?; + if running.0.provider.is_some() { + return super::provider::load_dependencies(app, running).await; + } + tauri::async_runtime::spawn_blocking(move || { + let operation = &running.0; + let guard = DependencyGuard { + base: HostGuard { + app: &app, + operation, + deadline: Instant::now() + Duration::from_secs(660), + }, + }; + let mut lease = None; + let mut output = None; + let loaded = (|| { + guard.checkpoint()?; + lease = Some( + app.state::() + .acquire(&operation.context.plugin_id)?, + ); + if operation.plan.output == "outputFile" { + output = Some(ModelOutputFile::create(&operation.output_file)?); + } + execute(operation, &HostCoreExecutor, &NativeBuildExecutor, &guard) + })(); + if loaded + .as_ref() + .err() + .is_some_and(|error| error.starts_with("plugin.build.cleanupFailed")) + { + // Keep the scratch file and package barrier until tree exit is + // proven. The shared owner remains occupied and emits no terminal. + operation.retain_failed_cleanup((lease.take(), output.take())); + return Err(loaded.err().unwrap()); + } + let (terminal, dependencies) = + loaded.unwrap_or_else(|error| (super::run::error_terminal(&error), None)); + let terminal = operation.complete_run(terminal, || guard.checkpoint())?; + let dependencies = (terminal.kind == "succeeded") + .then_some(dependencies) + .flatten(); + let result = BuildDependenciesResult { + review: review(operation)?, + truncated: dependencies.as_ref().is_some_and(|tree| tree.truncated), + dependency_tree: dependencies.as_ref().map(|tree| tree.tree.clone()), + dependencies: dependencies.map(|tree| tree.nodes), + terminal, + }; + drop(output); + drop(lease); + Ok(result) + }) + .await + .map_err(|_| "plugin.build.processFailed".to_string())? +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/dependencies_tests.rs b/windows/tauri/src-tauri/src/plugin_host/build/dependencies_tests.rs new file mode 100644 index 0000000000..fdc1c5f9db --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/dependencies_tests.rs @@ -0,0 +1,613 @@ +//! Real Core plans and dependency decoders with a controlled native executor. +//! Fixtures live only in an owned temporary workspace; no tool is spawned. +use super::super::build_registry::*; +use super::super::build_resolver::WindowsBuildToolResolver; +use super::dependencies::*; +use super::operation::*; +use super::output::*; +use super::process::ModelOutputFile; +use serde_json::{json, Value}; +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +struct Core; +impl BuildCoreExecutor for Core { + fn execute(&self, command: &str, payload: Value) -> Result { + super::super::scan::core_execute(command, payload) + } +} +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let fixture = + Self(std::env::temp_dir().join(format!("lithe dependencies {}", next_operation_id()))); + fixture.write("reactor/pom.xml", "4.0.0fixture.testroot1app"); + fixture.write( + "reactor/app/pom.xml", + "app1", + ); + fixture.write("tools/mvn.exe", "placeholder"); + fixture + } + fn write(&self, relative: &str, text: &str) { + let path = self.0.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + fn path(&self) -> String { + self.0.to_string_lossy().into_owned() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +struct Packages(BuildPackageInput); +impl BuildPackageSource for Packages { + fn accepted_packages(&self) -> Vec { + vec![self.0.clone()] + } + fn package(&self, id: &str) -> Option { + (id == self.0.plugin_id).then(|| self.0.clone()) + } +} +struct Options; +impl BuildOptionsPersistence for Options { + fn load(&self, _: &str) -> Result { + Ok(StoredBuildOptions::default()) + } + fn save(&self, _: &str, _: &StoredBuildOptions) -> Result<(), String> { + Ok(()) + } +} +fn setup(fixture: &Fixture, stdout: bool) -> (BuildRegistry, Packages, BuildModelContext) { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json"); + let mut manifest: Value = + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + manifest["buildSystems"][0]["invocation"]["runtimeDependencies"] = json!([]); + if stdout { + manifest["buildSystems"][0]["dependencies"] = json!({"source":"command","scopes":["workspace","project"],"arguments":["dependencies"],"output":"stdout","decoder":"litheDependencies","executesProjectCode":true,"mayUseNetwork":true}); + } + let package = Packages(BuildPackageInput { + plugin_id: manifest["id"].as_str().unwrap().into(), + version: "0.1.0".into(), + manifest, + content_identity: "original".into(), + }); + let mut registry = BuildRegistry::new(); + let view = registry + .refresh(&Core, &package, &Options, "main", &fixture.path()) + .unwrap(); + let context = registry + .model_context( + "main", + &fixture.path(), + &package.0.plugin_id, + "maven", + "reactor", + ) + .unwrap_or_else(|error| panic!("{error}: {}", serde_json::to_string(&view).unwrap())); + (registry, package, context) +} +#[derive(Default)] +struct Sink(Mutex>); +impl BuildOutputSink for Sink { + fn emit(&self, window: &str, event: Value) -> Result<(), String> { + assert_eq!(window, "main"); + self.0.lock().unwrap().push(event); + Ok(()) + } +} +fn operation( + fixture: &Fixture, + context: BuildModelContext, + project: Option, + sink: Arc, +) -> ModelOperation { + let id = next_operation_id(); + let output = fixture + .0 + .join("plugin-state") + .join(format!("{id}.dependencies.json")); + let (command, prepared) = plan( + &Core, + &context, + &BuildDependenciesSelection { + project_id: project, + }, + &fixture.0, + &fixture.0, + &output, + &id, + "fingerprint".into(), + sink, + ) + .unwrap(); + let response = Core.execute("build.invocationPlan",json!({"manifest":context.package.manifest,"buildSystemID":"maven","request":{"root":"reactor","platform":"windows"}})).unwrap(); + core_ok(&response).unwrap(); + let directory = + WindowsBuildToolResolver::instance_directory(&response["invocation"], &fixture.0).unwrap(); + let resolver = WindowsBuildToolResolver::with_environment( + BTreeMap::from([( + "PATH".into(), + fixture.0.join("tools").to_string_lossy().into_owned(), + )]), + BTreeMap::new(), + ); + let mut operation = ModelOperation::new( + id, + context, + command, + response["invocation"].clone(), + resolver, + output, + directory, + ); + operation.dependencies = Some(prepared); + operation +} +struct Guard<'a> { + operation: &'a ModelOperation, + registry: Option<&'a BuildRegistry>, +} +impl ModelExecutionGuard for Guard<'_> { + fn checkpoint(&self) -> Result<(), String> { + if self.cancelled() { + Err("plugin.build.cancelled".into()) + } else { + Ok(()) + } + } + fn cancelled(&self) -> bool { + self.operation.cancelled() + || self + .registry + .is_some_and(|registry| !registry.context_is_current(&self.operation.context)) + } +} +type Callback<'a> = dyn FnMut(lithe_git_host::Stream, &[u8]) -> Result<(), String> + 'a; +struct Executor { + action: F, + plans: RefCell>, +} +impl) -> BuildRunProcessOutput> BuildProcessExecutor + for Executor +{ + fn run( + &self, + plan: &BuildProcessPlan, + guard: &dyn ModelExecutionGuard, + ) -> Result { + guard.checkpoint()?; + self.plans.borrow_mut().push(plan.clone()); + assert_eq!(plan.arguments, ["--version"]); + Ok(BuildProcessOutput { + stdout: b"Apache Maven 3.9.9".to_vec(), + stderr: vec![], + }) + } + fn run_command( + &self, + plan: &BuildProcessPlan, + guard: &dyn ModelExecutionGuard, + output: &mut Callback<'_>, + ) -> BuildRunProcessOutput { + guard.checkpoint().unwrap(); + self.plans.borrow_mut().push(plan.clone()); + (self.action)(plan, output) + } +} +fn outcome(error: Option<&str>) -> BuildRunProcessOutput { + BuildRunProcessOutput { + exit_code: Some(if error.is_some() { 1 } else { 0 }), + started: true, + failure: error.map(str::to_string), + started_at_millis: Some(1), + } +} +fn executor) -> BuildRunProcessOutput>( + action: F, +) -> Executor { + Executor { + action, + plans: RefCell::new(vec![]), + } +} + +#[test] +fn maven_output_file_plan_uses_exact_native_project_and_core_decoder() { + let fixture = Fixture::new(); + let (_, _, context) = setup(&fixture, false); + let sink = Arc::new(Sink::default()); + let project = context.model["projects"] + .as_array() + .unwrap() + .iter() + .find(|project| project["path"] == "reactor/app") + .unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let operation = operation(&fixture, context, Some(project), sink.clone()); + assert!( + !operation.output_file.exists(), + "prepare allocates no file and runs no probes" + ); + let file = ModelOutputFile::create(&operation.output_file).unwrap(); + let executor = executor(|plan, output| { + assert_eq!( + plan.directory, + fixture.0.join("reactor").canonicalize().unwrap() + ); + assert!(plan.arguments.windows(2).any(|pair| pair == ["-pl", "app"])); + assert!(plan + .arguments + .iter() + .any(|arg| arg == "org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree")); + std::fs::write( + plan.output_file.as_ref().unwrap(), + "fixture.test:app:jar:1\n\\- dep.test:library:jar:2:compile\n", + ) + .unwrap(); + output( + lithe_git_host::Stream::Stderr, + b"download log is not dependency data\n", + ) + .unwrap(); + outcome(None) + }); + let (terminal, tree) = execute( + &operation, + &Core, + &executor, + &Guard { + operation: &operation, + registry: None, + }, + ) + .unwrap(); + assert_eq!(terminal.kind, "succeeded"); + let tree = tree.unwrap(); + assert_eq!(tree.nodes[0]["name"], "library"); + assert_eq!(tree.tree["project"], "reactor/app"); + assert_eq!(tree.tree["dependencies"], tree.nodes); + assert_eq!(tree.tree["truncated"], tree.truncated); + operation.complete_run(terminal, || Ok(())).unwrap(); + let plans = executor.plans.borrow(); + assert_eq!(plans[0].executable, plans[1].executable); + assert_eq!(plans[0].environment, plans[1].environment); + assert_eq!(sink.0.lock().unwrap().last().unwrap()["type"], "terminal"); + drop(file); + assert!(!operation.output_file.exists()); +} + +#[test] +fn stdout_canonical_dependencies_are_decoded_only_after_successful_execution() { + let fixture = Fixture::new(); + let (_, _, context) = setup(&fixture, true); + let operation = operation(&fixture, context, None, Arc::new(Sink::default())); + let executor = executor(|plan, output| { + assert!(plan.output_file.is_none()); + output(lithe_git_host::Stream::Stdout,br#"{"format":"litheDependencies","version":1,"dependencies":[{"name":"library","children":[{"name":"transitive"}]}]}"#).unwrap(); + outcome(None) + }); + let (terminal, tree) = execute( + &operation, + &Core, + &executor, + &Guard { + operation: &operation, + registry: None, + }, + ) + .unwrap(); + assert_eq!(terminal.kind, "succeeded"); + let tree = tree.unwrap(); + assert_eq!(tree.nodes[0]["children"][0]["name"], "transitive"); + assert_eq!(tree.tree["project"], "reactor"); + assert_eq!(tree.tree["dependencies"], tree.nodes); +} + +#[test] +fn pending_reviews_share_busy_and_cancellation_with_tasks_and_models() { + let fixture = Fixture::new(); + let (_, _, context) = setup(&fixture, true); + let sink = Arc::new(Sink::default()); + let operations = BuildOperations::default(); + let review = operations + .insert(operation(&fixture, context.clone(), None, sink.clone())) + .unwrap(); + let mut model = operation(&fixture, context.clone(), None, sink.clone()); + model.dependencies = None; + assert!(operations.insert(model).is_err()); + assert!(operations + .claim("workspace-other", &review.operation_id) + .is_err()); + let pending = operations.get("main", &review.operation_id).unwrap(); + pending.cancel(); + pending.cancel(); + assert!(operations.claim("main", &review.operation_id).is_err()); + assert_eq!(sink.0.lock().unwrap().len(), 1); + let review = operations + .insert(operation(&fixture, context, None, sink)) + .unwrap(); + let running = operations.claim("main", &review.operation_id).unwrap(); + running.0.cancel(); + assert!(!running.0.finished()); + assert!(operations.claim("main", &review.operation_id).is_err()); + let owner = running.0.clone(); + drop(running); + assert!(owner.finished()); +} + +#[test] +fn cleanup_failure_retains_the_same_owner_and_its_output_file_without_terminal() { + let fixture = Fixture::new(); + let (_, _, context) = setup(&fixture, false); + let sink = Arc::new(Sink::default()); + let operations = BuildOperations::default(); + let review = operations + .insert(operation(&fixture, context.clone(), None, sink.clone())) + .unwrap(); + let running = operations.claim("main", &review.operation_id).unwrap(); + let output = ModelOutputFile::create(&running.0.output_file).unwrap(); + let executor = executor(|_, _| outcome(Some("plugin.build.cleanupFailed"))); + assert_eq!( + execute( + &running.0, + &Core, + &executor, + &Guard { + operation: &running.0, + registry: None + } + ) + .err() + .as_deref(), + Some("plugin.build.cleanupFailed") + ); + running.0.retain_failed_cleanup(output); + let owner = running.0.clone(); + drop(running); + assert!(!owner.finished()); + assert!(owner.output_file.exists()); + assert!(sink.0.lock().unwrap().is_empty()); + assert!(operations + .insert(operation(&fixture, context, None, sink)) + .is_err()); + drop(owner); + drop(operations); +} + +#[test] +fn cancelled_stream_drops_tail_and_never_decodes_a_tree() { + let fixture = Fixture::new(); + let (_, _, context) = setup(&fixture, true); + let sink = Arc::new(Sink::default()); + let operations = BuildOperations::default(); + let review = operations + .insert(operation(&fixture, context, None, sink.clone())) + .unwrap(); + let running = operations.claim("main", &review.operation_id).unwrap(); + let executor = executor(|_, output| { + output(lithe_git_host::Stream::Stderr, b"before\n").unwrap(); + running.0.cancel(); + assert!(output(lithe_git_host::Stream::Stdout, b"late").is_err()); + outcome(Some("plugin.build.cancelled")) + }); + let (terminal, tree) = execute( + &running.0, + &Core, + &executor, + &Guard { + operation: &running.0, + registry: None, + }, + ) + .unwrap(); + assert_eq!(terminal.kind, "cancelled"); + assert!(tree.is_none()); + running.0.complete_run(terminal, || Ok(())).unwrap(); + assert_eq!(sink.0.lock().unwrap().len(), 2); +} + +#[test] +fn registry_changes_before_confirmation_reject_without_executing() { + for change in [ + "option", + "workspace", + "window", + "plugin", + "model", + "replacement", + ] { + let fixture = Fixture::new(); + let (mut registry, packages, context) = setup(&fixture, true); + let operation = operation(&fixture, context.clone(), None, Arc::new(Sink::default())); + match change { + "option" => { + registry + .set_option( + &Core, + &packages, + &Options, + "main", + &fixture.path(), + &context.plugin_id, + "maven", + "reactor", + "offline", + Some(json!(true)), + ) + .unwrap(); + } + "workspace" => registry.close_workspace("main", &fixture.path()), + "window" => registry.close_window("main"), + "plugin" => registry.drop_plugin(&context.plugin_id), + "model" => { + let before = registry.model_fingerprint(&Core, &context).unwrap(); + registry + .apply_command_model(&Core, &context, context.model.clone(), &before) + .unwrap(); + } + "replacement" => { + let mut replacement = packages.0.clone(); + replacement.content_identity = "same-version:new-bytes".into(); + registry + .sync_check( + &Core, + &Packages(replacement), + "main", + &fixture.path(), + &context.plugin_id, + "maven", + "reactor", + ) + .unwrap(); + } + _ => unreachable!(), + } + let executor = executor(|_, _| panic!("stale review must never execute")); + assert!(execute( + &operation, + &Core, + &executor, + &Guard { + operation: &operation, + registry: Some(®istry) + } + ) + .is_err()); + assert!(executor.plans.borrow().is_empty()); + } +} + +#[test] +fn selection_and_provider_sources_cannot_inject_native_plan_fields() { + for field in [ + "project", + "root", + "executable", + "arguments", + "environment", + "outputFile", + "permissions", + ] { + let mut input = json!({"projectId":null}); + input[field] = json!("untrusted"); + assert!(serde_json::from_value::(input).is_err()); + } + let fixture = Fixture::new(); + let (_, _, mut context) = setup(&fixture, true); + let sink = Arc::new(Sink::default()); + let prepare = |context: &BuildModelContext, selection: BuildDependenciesSelection| { + plan( + &Core, + context, + &selection, + &fixture.0, + &fixture.0, + &fixture.0.join("unused"), + "review", + "stamp".into(), + sink.clone(), + ) + }; + assert!(prepare( + &context, + BuildDependenciesSelection { + project_id: Some("../forged".into()) + } + ) + .is_err()); + context.model_stale = true; + assert!(prepare( + &context, + BuildDependenciesSelection { + project_id: context.model["projects"][0]["id"] + .as_str() + .map(str::to_string) + } + ) + .is_err()); + context.package.manifest["buildSystems"][0]["dependencies"] = + json!({"source":"provider","scopes":["workspace"]}); + // A provider source without its process declaration is still rejected by + // Core. Valid Provider declarations use the separate native session route. + assert!(prepare(&context, BuildDependenciesSelection { project_id: None }).is_err()); +} + +#[test] +fn malformed_output_and_unsuccessful_exit_never_return_a_dependency_tree() { + for failure in [ + None, + Some("plugin.build.timeout"), + Some("plugin.build.outputLimit"), + Some("plugin.build.processFailed"), + ] { + let fixture = Fixture::new(); + let (_, _, context) = setup(&fixture, false); + let operation = operation(&fixture, context, None, Arc::new(Sink::default())); + let output = ModelOutputFile::create(&operation.output_file).unwrap(); + let executor = executor(|plan, _| { + std::fs::write(plan.output_file.as_ref().unwrap(), "not dependency data").unwrap(); + outcome(failure) + }); + let (terminal, tree) = execute( + &operation, + &Core, + &executor, + &Guard { + operation: &operation, + registry: None, + }, + ) + .unwrap(); + assert_eq!(terminal.kind, "failed"); + assert!(tree.is_none()); + drop(output); + assert!(!operation.output_file.exists()); + } +} + +#[test] +fn cancellation_winning_after_decode_prevents_success_publication() { + let fixture = Fixture::new(); + let (_, _, context) = setup(&fixture, true); + let sink = Arc::new(Sink::default()); + let operations = BuildOperations::default(); + let review = operations + .insert(operation(&fixture, context, None, sink.clone())) + .unwrap(); + let running = operations.claim("main", &review.operation_id).unwrap(); + let executor = executor(|_, output| { + output( + lithe_git_host::Stream::Stdout, + br#"{"format":"litheDependencies","version":1,"dependencies":[{"name":"library"}]}"#, + ) + .unwrap(); + outcome(None) + }); + let (terminal, tree) = execute( + &running.0, + &Core, + &executor, + &Guard { + operation: &running.0, + registry: None, + }, + ) + .unwrap(); + assert!(tree.is_some(), "dependency execution failed: {terminal:?}"); + running.0.cancel(); + let terminal = running.0.complete_run(terminal, || Ok(())).unwrap(); + assert_eq!(terminal.kind, "cancelled"); + assert_eq!( + sink.0.lock().unwrap().last().unwrap()["terminal"]["kind"], + "cancelled" + ); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/host.rs b/windows/tauri/src-tauri/src/plugin_host/build/host.rs new file mode 100644 index 0000000000..24559f8e5f --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/host.rs @@ -0,0 +1,537 @@ +//! Tauri composition for the operation coordinator. Never hold the registry +//! lock while waiting for consent, preparing runtimes or executing a process. + +use super::super::build_registry::BuildModelContext; +use super::super::build_resolver::{RuntimeExecutableInput, WindowsBuildToolResolver}; +use super::output::{BuildOutputSink, BuildRunTerminal}; +use super::run::{prepare_run_plan, run_review, BuildRunReview, BuildRunSelection}; +use super::{operation::*, process::*, *}; +use serde_json::json; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tauri::Emitter; + +const OPERATION_TIMEOUT: Duration = Duration::from_secs(180); + +/// Capture a configured runtime without executing it before the review. The +/// Java launch adapter validates the candidate JDK, then a round-trip identity +/// check ensures its JAVA_HOME refers to the exact selected executable. +pub(super) fn explicit_runtime_input( + id: &str, + executable: PathBuf, +) -> Result { + if !executable.is_absolute() { + return Err("plugin.build.invalidPlan: runtime paths must be absolute".into()); + } + let mut environment = BTreeMap::new(); + if id.eq_ignore_ascii_case("java") { + let candidate = executable + .parent() + .and_then(std::path::Path::parent) + .ok_or("plugin.build.configuredToolUnavailable: Java must belong to a JDK")?; + let home = crate::run::resolve_java_home(candidate, &candidate.to_string_lossy()) + .map_err(|_| "plugin.build.configuredToolUnavailable: Java must belong to a JDK")? + .ok_or("plugin.build.configuredToolUnavailable: Java must belong to a JDK")?; + let selected = executable.canonicalize().map_err(|_| { + "plugin.build.configuredToolUnavailable: configured Java is unavailable" + })?; + let resolved = crate::run::java_executable(std::path::Path::new(&home)) + .and_then(|path| path.canonicalize().ok()); + if resolved.as_ref() != Some(&selected) { + return Err( + "plugin.build.configuredToolUnavailable: Java does not match its JDK home".into(), + ); + } + environment.insert("JAVA_HOME".into(), home); + } + Ok(RuntimeExecutableInput { + executable, + environment, + explicit: true, + }) +} + +pub(super) fn verified_identity( + app: &AppHandle, + context: &BuildModelContext, +) -> Result { + let package = super::super::launch::verified_package_by_id(app, &context.plugin_id)?; + if package_input(&package).as_ref() != Some(&context.package) { + return Err("plugin.build.packageChanged: reload the build instance".into()); + } + Ok(package) +} + +pub(super) struct HostGuard<'a> { + pub app: &'a AppHandle, + pub operation: &'a ModelOperation, + pub deadline: Instant, +} +impl ModelExecutionGuard for HostGuard<'_> { + fn start_permit(&self) -> Result, String> { + self.checkpoint()?; + let permit = self.operation.start_permit()?; + if self.cancelled() { + return Err("plugin.build.cancelled".into()); + } + Ok(Box::new(permit)) + } + fn checkpoint(&self) -> Result<(), String> { + if self.operation.cancelled() { + return Err("plugin.build.cancelled".into()); + } + if Instant::now() >= self.deadline { + return Err("plugin.build.timeout".into()); + } + if self.cancelled() { + return Err("plugin.build.staleContext".into()); + } + if !self + .app + .state::() + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")? + .context_is_current(&self.operation.context) + { + return Err("plugin.build.staleContext".into()); + } + verified_identity(self.app, &self.operation.context)?; + if let Some(receipt) = self + .operation + .settings_reload + .lock() + .map_err(|_| "plugin.settings.unavailable")? + .as_ref() + { + super::super::settings::reload::check( + self.app, + &self.operation.context.window_label, + receipt, + )?; + } + let directory = WindowsBuildToolResolver::instance_directory( + &self.operation.invocation, + &PathBuf::from(&self.operation.context.workspace_root), + ) + .map_err(|error| error.to_string())?; + if directory != self.operation.working_directory { + return Err("plugin.build.staleContext: workspace path changed".into()); + } + Ok(()) + } + fn cancelled(&self) -> bool { + self.operation.cancelled() + || Instant::now() >= self.deadline + || self + .app + .get_webview_window(&self.operation.context.window_label) + .is_none() + || self + .app + .state::() + .blocked(&self.operation.context.plugin_id) + || self + .app + .state::() + .registry + .try_lock() + .map(|registry| !registry.context_is_current(&self.operation.context)) + .unwrap_or_else(|error| matches!(error, std::sync::TryLockError::Poisoned(_))) + } +} + +pub(super) async fn prepare( + app: AppHandle, + window_label: String, + workspace_root: String, + plugin_id: String, + build_system_id: String, + root: String, + runtime_executables: BTreeMap, +) -> Result { + prepare_with_refresh( + app, + window_label, + workspace_root, + plugin_id, + build_system_id, + root, + runtime_executables, + false, + ) + .await +} + +pub(super) async fn prepare_with_refresh( + app: AppHandle, + window_label: String, + workspace_root: String, + plugin_id: String, + build_system_id: String, + root: String, + runtime_executables: BTreeMap, + refresh: bool, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + let context = state.registry.lock().map_err(|_| "build registry mutex poisoned")? + .model_context(&window_label, &workspace_root, &plugin_id, &build_system_id, &root)?; + let _lease = app.state::().acquire(&plugin_id)?; + let package = verified_identity(&app, &context)?; + let id = next_operation_id(); + let state_directory = store::plugin_state_directory(&app, &plugin_id)?; + let output_file = state_directory.join(format!("{id}.model.json")); + let response = HostCoreExecutor.execute("build.modelPlan", json!({ + "manifest": context.package.manifest, "buildSystemID": build_system_id, + "request": {"root": root, "options": context.options, + "pluginDirectory": package.root, "workspaceStateDirectory": state_directory.join(store::workspace_state_key(&workspace_root)), + "outputFile": output_file, "refresh": refresh}, + }))?; + core_ok(&response)?; + let provider_plan=response["plan"]["provider"].is_object().then(||response["plan"].clone()); + let plan: ModelCommandPlan = if let Some(plan)=&provider_plan { super::provider::command(plan)? } else { + serde_json::from_value(response["plan"].clone()).map_err(|error| format!("plugin.build.invalidPlan: {error}"))? + }; + if plan.working_directory != context.root || !matches!(plan.output.as_str(), "stdout" | "outputFile" | "provider") { + return Err("plugin.build.invalidPlan: unsupported model command".into()); + } + // Core invocation settings are global only: project files cannot + // select an executable. Values and runtime preferences are frozen + // before the review, never re-read after it has been accepted. + let settings = super::super::settings::load_layers(&app, &plugin_id, &workspace_root)?; + let invocation = HostCoreExecutor.execute("build.invocationPlan", json!({ + "manifest": context.package.manifest, "buildSystemID": build_system_id, + "request": {"root": root, "platform": "windows", "settings": settings.global}, + }))?; + core_ok(&invocation)?; + let runtimes = runtime_executables.into_iter().filter(|(_, path)| !path.is_empty()).map(|(id, path)| { + let input = explicit_runtime_input(&id, PathBuf::from(path))?; + Ok((id, input)) + }).collect::, String>>()?; + let working_directory = WindowsBuildToolResolver::instance_directory(&invocation["invocation"], &PathBuf::from(&workspace_root)) + .map_err(|error| format!("{}: {error}", error.code()))?; + verified_identity(&app, &context)?; + let resolver = WindowsBuildToolResolver::from_current_process(runtimes.clone()).freeze_discovery() + .map_err(|error| error.to_string())?; + let prepared_provider=provider_plan.map(|plan|super::provider::prepare(&app,&context,plan,runtimes)).transpose()?; + let mut operation = ModelOperation::new(id.clone(), context, plan, invocation["invocation"].clone(), + resolver, output_file, working_directory); + operation.provider=prepared_provider; + let review = state.operations.insert(operation)?; + let operation = state.operations.get(&window_label, &id)?; + if !state.registry.lock().map_err(|_| "build registry mutex poisoned")?.context_is_current(&operation.context) { + operation.cancel(); return Err("plugin.build.staleContext".into()); + } + Ok(review) + }).await.map_err(|error| format!("build preparation task failed: {error}"))? +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildModelLoadResult { + pub operation_id: String, + pub view: super::super::build_registry::BuildView, + /// Separate from model stdout; the caller may present it in Build Output. + pub stderr: String, +} + +pub(super) async fn load( + app: AppHandle, + window_label: String, + operation_id: String, +) -> Result { + if app + .state::() + .operations + .get(&window_label, &operation_id)? + .run + .is_some() + || app + .state::() + .operations + .get(&window_label, &operation_id)? + .dependencies + .is_some() + { + return Err("plugin.build.invalidPlan".into()); + } + // Claim before spawning so duplicate confirmations cannot start two jobs. + let running = app + .state::() + .operations + .claim(&window_label, &operation_id)?; + if running.0.provider.is_some() { + return super::provider::load_model(app, running).await; + } + tauri::async_runtime::spawn_blocking(move || { + let operation = &running.0; + let guard = HostGuard { + app: &app, + operation, + deadline: Instant::now() + OPERATION_TIMEOUT, + }; + guard.checkpoint()?; + let _lease = app + .state::() + .acquire(&operation.context.plugin_id)?; + guard.checkpoint()?; + let _output = if operation.plan.output == "outputFile" { + Some(ModelOutputFile::create(&operation.output_file)?) + } else { + None + }; + let before = app + .state::() + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")? + .model_fingerprint(&HostCoreExecutor, &operation.context)?; + let loaded = match execute_model(operation, &HostCoreExecutor, &NativeBuildExecutor, &guard) + { + Ok(loaded) => loaded, + Err(error) if error.starts_with("plugin.build.cleanupFailed") => { + operation.retain_failed_cleanup(_lease); + return Err(error); + } + Err(error) => return Err(error), + }; + let view = operation.publish(|| { + guard.checkpoint()?; + app.state::() + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")? + .apply_command_model(&HostCoreExecutor, &operation.context, loaded.model, &before) + })?; + Ok(BuildModelLoadResult { + operation_id, + view, + stderr: loaded.stderr, + }) + // `running` drops after output-file cleanup and NativeLease release. + }) + .await + .map_err(|error| format!("build model task failed: {error}"))? +} + +pub(super) async fn cancel( + app: AppHandle, + window_label: String, + operation_id: String, +) -> Result<(), String> { + let Ok(operation) = app + .state::() + .operations + .get(&window_label, &operation_id) + else { + // A completed operation may already have been pruned. Cancellation + // is idempotent and never operates on another window's process owner. + return Ok(()); + }; + cancel_owned(&app, &operation).await +} + +async fn cancel_owned(app: &AppHandle, operation: &Arc) -> Result<(), String> { + operation.cancel(); + if let Some(provider) = &operation.provider { + super::super::provider::drain_initialization( + app, + &operation.context.window_label, + provider.review.token, + ) + .await + .map_err(|_| "plugin.build.cleanupFailed".to_string())?; + operation.finish_provider_cleanup(); + } + operation.wait_finished().await +} + +pub(super) async fn drain( + app: &AppHandle, + operations: Vec>, +) -> Result<(), String> { + drain_with(operations, |operation| async move { + cancel_owned(app, &operation).await + }) + .await +} + +pub(super) async fn drain_with( + operations: Vec>, + mut cleanup: F, +) -> Result<(), String> +where + F: FnMut(Arc) -> Cleanup, + Cleanup: std::future::Future>, +{ + let mut failure = None; + for operation in operations { + if let Err(error) = cleanup(operation).await { + failure.get_or_insert(error); + } + } + failure.map_or(Ok(()), Err) +} + +pub(super) struct WindowBuildOutput(pub AppHandle); +impl BuildOutputSink for WindowBuildOutput { + fn emit(&self, window_label: &str, event: Value) -> Result<(), String> { + // Only the initiating product window receives these events, never + // plugin pages or other product windows sharing the application. + self.0 + .emit_to(window_label, "declarative-build-output", event) + .map_err(|_| "plugin.build.outputUnavailable".into()) + } +} + +pub(super) async fn prepare_run( + app: AppHandle, + window_label: String, + workspace_root: String, + plugin_id: String, + build_system_id: String, + root: String, + selection: BuildRunSelection, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if window_label != "main" && !window_label.starts_with("workspace-") { + return Err("plugin.build.staleContext".into()); + } + let state = app.state::(); + let context = state + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")? + .model_context( + &window_label, + &workspace_root, + &plugin_id, + &build_system_id, + &root, + )?; + let _lease = app + .state::() + .acquire(&plugin_id)?; + let package = verified_identity(&app, &context)?; + let id = next_operation_id(); + let state_directory = store::plugin_state_directory(&app, &plugin_id)?; + let run = prepare_run_plan( + &HostCoreExecutor, + &context, + &selection, + &package.root, + &state_directory.join(store::workspace_state_key(&workspace_root)), + &id, + Arc::new(WindowBuildOutput(app.clone())), + )?; + let settings = super::super::settings::load_layers(&app, &plugin_id, &workspace_root)?; + let invocation = HostCoreExecutor.execute( + "build.invocationPlan", + json!({ + "manifest":context.package.manifest, "buildSystemID":build_system_id, + "request":{"root":root,"platform":"windows","settings":settings.global}, + }), + )?; + core_ok(&invocation)?; + let working_directory = WindowsBuildToolResolver::instance_directory( + &invocation["invocation"], + &PathBuf::from(&workspace_root), + ) + .map_err(|error| format!("{}: {error}", error.code()))?; + let resolver = WindowsBuildToolResolver::from_current_process(BTreeMap::new()) + .freeze_discovery() + .map_err(|error| error.to_string())?; + // Shared operation ownership, busy slot, runtime resolver and guards. + // No model output allocation is used for a task/test operation. + let plan = ModelCommandPlan { + arguments: run.plan.arguments.clone(), + environment: run.plan.environment.clone(), + working_directory: run.plan.working_directory.clone(), + output: "stdout".into(), + executes_project_code: true, + may_use_network: true, + }; + let mut operation = ModelOperation::new( + id.clone(), + context, + plan, + invocation["invocation"].clone(), + resolver, + PathBuf::new(), + working_directory, + ); + operation.run = Some(run); + let review = run_review(&operation)?; + verified_identity(&app, &operation.context)?; + state.operations.insert(operation)?; + let operation = state.operations.get(&window_label, &id)?; + if !state + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")? + .context_is_current(&operation.context) + { + operation.cancel(); + return Err("plugin.build.staleContext".into()); + } + Ok(review) + }) + .await + .map_err(|_| "plugin.build.processFailed".to_string())? +} + +pub(super) async fn execute_run( + app: AppHandle, + window_label: String, + operation_id: String, +) -> Result { + let operation = app + .state::() + .operations + .get(&window_label, &operation_id)?; + if operation.run.is_none() { + return Err("plugin.build.invalidPlan".into()); + } + let running = app + .state::() + .operations + .claim(&window_label, &operation_id)?; + tauri::async_runtime::spawn_blocking(move || { + let operation = &running.0; + let guard = HostGuard { + app: &app, + operation, + deadline: Instant::now() + Duration::from_secs(660), + }; + let mut lease = None; + let result = (|| { + guard.checkpoint()?; + lease = Some( + app.state::() + .acquire(&operation.context.plugin_id)?, + ); + guard.checkpoint()?; + super::run::execute_run(operation, &HostCoreExecutor, &NativeBuildExecutor, &guard) + })(); + if result + .as_ref() + .err() + .is_some_and(|error| error.starts_with("plugin.build.cleanupFailed")) + { + // Preserve occupancy and package mutation barrier on failed cleanup. + operation.retain_failed_cleanup(lease.take()); + return result; + } + let terminal = result.unwrap_or_else(|error| super::run::error_terminal(&error)); + // Keep package mutation blocked through the final identity check and + // publication. Cancellation shares this publication lock. + let completed = operation.complete_run(terminal, || guard.checkpoint()); + drop(lease); + completed + }) + .await + .map_err(|_| "plugin.build.processFailed".to_string())? +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/host_integration.rs b/windows/tauri/src-tauri/src/plugin_host/build/host_integration.rs new file mode 100644 index 0000000000..b93c300c77 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/host_integration.rs @@ -0,0 +1,28 @@ +//! Passive model bridge to the existing Core backend. Maven's integration +//! already calls `project::maven::scan`, the same owner as the legacy +//! `maven.scan` entry. This host forwards the exact instance root/options; +//! it does not parse POMs, infer modules, start JDT, or execute a wrapper. + +use super::super::build_registry::BuildCoreExecutor; +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +pub(crate) fn read_passive_model( + core: &dyn BuildCoreExecutor, + manifest: &Value, + build_system_id: &str, + workspace_root: &str, + root: &str, + options: &BTreeMap, +) -> Result { + // Core validates the declared source/integration and owns normalization. + // In particular, modelPlan is intentionally unavailable for a passive + // source; invoking it would incorrectly require tools/consent to read it. + core.execute( + "build.decodeModel", + json!({ + "manifest": manifest, "buildSystemID": build_system_id, + "request": { "workspaceRoot": workspace_root, "root": root, "options": options }, + }), + ) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/host_integration_tests.rs b/windows/tauri/src-tauri/src/plugin_host/build/host_integration_tests.rs new file mode 100644 index 0000000000..d3e91ac6b4 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/host_integration_tests.rs @@ -0,0 +1,653 @@ +//! Real Core/legacy Maven backend regressions for passive model admission, +//! exact reactor roots, options, package identity and publication. All races +//! are controlled at a Core return boundary; no tools, JDT or timers run. + +use super::super::build_registry::*; +use super::operation::next_operation_id; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, +}; + +const SYSTEM: &str = "fixture-reactor"; + +struct Workspace(PathBuf); +impl Workspace { + fn new() -> Self { + let workspace = + Self(std::env::temp_dir().join(format!("lithe maven model {}", next_operation_id()))); + workspace.write("north/pom.xml", &pom("north", "pomlibalpha")); + workspace.write("north/lib/pom.xml", &pom("library", "deepsources/mainsources/testassets/main")); + workspace.write("north/lib/deep/pom.xml", &pom("deep", "")); + workspace.write( + "south/pom.xml", + &pom( + "south", + "beta", + ), + ); + for root in ["north", "south"] { + workspace.write(&format!("{root}/mvnw.cmd"), "@echo off\r\nexit /b 99\r\n"); + workspace.write( + &format!("{root}/.mvn/wrapper/maven-wrapper.properties"), + "distributionUrl=https://example.invalid/maven.zip\n", + ); + } + workspace + } + fn path(&self) -> String { + self.0.to_string_lossy().into_owned() + } + fn write(&self, relative: &str, text: &str) { + let path = self.0.join(relative); + std::fs::create_dir_all(path.parent().expect("parent")).expect("directory"); + std::fs::write(path, text).expect("fixture file"); + } +} +impl Drop for Workspace { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +fn pom(artifact: &str, body: &str) -> String { + format!("4.0.0fixture.test{artifact}1{body}") +} +fn declaration() -> Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json"); + let mut manifest: Value = + serde_json::from_str(&std::fs::read_to_string(path).expect("fixture")) + .expect("manifest JSON"); + // Integration dispatch belongs to Core's declaration, never a host test + // for one plugin ID or the literal build-system ID `maven`. + manifest["id"] = json!("dev.lithe.fixture.alternate-reactor"); + manifest["buildSystems"][0]["id"] = json!(SYSTEM); + manifest["activationEvents"] = json!([format!("onBuildSystem:{SYSTEM}")]); + manifest +} +struct Packages(Mutex>); +impl Packages { + fn new() -> Self { + let manifest = declaration(); + Self(Mutex::new(Some(BuildPackageInput { + plugin_id: manifest["id"].as_str().expect("ID").into(), + version: "0.1.0".into(), + manifest, + content_identity: "verified-installation:first".into(), + }))) + } + fn input(&self) -> BuildPackageInput { + self.0.lock().expect("package").clone().expect("enabled") + } +} +impl BuildPackageSource for Packages { + fn accepted_packages(&self) -> Vec { + self.0 + .lock() + .expect("package") + .clone() + .into_iter() + .collect() + } + fn package(&self, id: &str) -> Option { + self.0 + .lock() + .expect("package") + .as_ref() + .filter(|package| package.plugin_id == id) + .cloned() + } +} +#[derive(Default)] +struct Options { + values: Mutex, + saves: AtomicUsize, +} +impl BuildOptionsPersistence for Options { + fn load(&self, _: &str) -> Result { + Ok(self.values.lock().expect("options").clone()) + } + fn save(&self, _: &str, options: &StoredBuildOptions) -> Result<(), String> { + *self.values.lock().expect("options") = options.clone(); + self.saves.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} +type CoreBoundary = Arc; +#[derive(Default)] +struct Core { + seen: Mutex>, + after: Option, +} +impl BuildCoreExecutor for Core { + fn execute(&self, command: &str, payload: Value) -> Result { + self.seen + .lock() + .expect("calls") + .push((command.into(), payload.clone())); + let result = super::super::scan::core_execute(command, payload.clone()); + if let Some(after) = &self.after { + after(command, &payload); + } + result + } +} +fn instance(view: &BuildView, root: &str) -> Value { + view.instances + .iter() + .find(|instance| instance["root"] == root) + .expect("instance") + .clone() +} +fn context( + registry: &BuildRegistry, + workspace: &Workspace, + packages: &Packages, +) -> BuildModelContext { + registry + .model_context( + "main", + &workspace.path(), + &packages.input().plugin_id, + SYSTEM, + "north", + ) + .expect("context") +} +fn assert_legacy_projects(model: &Value, root: &str, project: &Value) { + let relative = project["relativePath"].as_str().expect("backend path"); + let path = if relative == "." { + root.into() + } else { + format!("{root}/{relative}") + }; + let normalized = model["projects"] + .as_array() + .expect("projects") + .iter() + .find(|candidate| candidate["path"] == path) + .expect("backend project in build model"); + assert_eq!(normalized["name"], project["artifactId"]); + for property in ["artifactId", "packaging", "groupId", "version"] { + if !project[property].is_null() { + assert_eq!(normalized["properties"][property], project[property]); + } + } + for source in project["sourceRoots"].as_array().expect("source roots") { + let kind = match source["kind"].as_str().expect("kind") { + "mainJava" => "source", + "mainResources" => "resource", + "testJava" => "testSource", + "testResources" => "testResource", + "generatedMain" => "generatedSource", + "generatedTest" => "generatedTestSource", + other => panic!("new backend source kind needs an adapter assertion: {other}"), + }; + let source_path = format!("{path}/{}", source["path"].as_str().expect("source path")); + assert!( + model["contentRoots"] + .as_array() + .expect("content roots") + .iter() + .any(|item| item["projectID"] == normalized["id"] + && item["path"] == source_path + && item["kind"] == kind), + "{source_path}" + ); + } + for child in project["modules"].as_array().expect("modules") { + assert_legacy_projects(model, root, child); + } +} + +#[test] +fn passive_models_reuse_legacy_maven_facts_with_exact_instance_roots() { + let workspace = Workspace::new(); + // Include an ancestor POM, while the declaration explicitly keeps each + // root separate. Each child still must read its own reactor, not this POM. + workspace.write("pom.xml", &pom("ancestor", "pom")); + let packages = Packages::new(); + packages + .0 + .lock() + .expect("package") + .as_mut() + .expect("enabled") + .manifest["buildSystems"][0]["detection"]["nesting"] = json!("separate"); + let core = Core::default(); + let mut registry = BuildRegistry::new(); + let view = registry + .refresh( + &core, + &packages, + &Options::default(), + "main", + &workspace.path(), + ) + .expect("passive refresh"); + for root in ["north", "south"] { + let backend = super::super::scan::core_execute( + "maven.scan", + json!({"root":workspace.0.join(root), "paths":["pom.xml"]}), + ) + .expect("legacy Maven backend"); + let instance = instance(&view, root); + assert_eq!(instance["modelSource"], "hostIntegration"); + assert!(!instance["model"]["projects"] + .as_array() + .expect("projects") + .is_empty()); + assert_legacy_projects(&instance["model"], root, &backend); + let profiles: Vec<_> = backend["profiles"] + .as_array() + .expect("profiles") + .iter() + .map(|profile| profile["id"].clone()) + .collect(); + assert_eq!( + instance["model"]["optionValues"]["profiles"], + json!(profiles) + ); + assert_eq!(instance["stale"], false); + } + let calls = core.seen.lock().expect("calls"); + assert!( + calls.iter().all(|(command, _)| matches!( + command.as_str(), + "build.detect" | "build.decodeModel" | "build.resolveOptions" | "build.syncFingerprint" + )), + "passive discovery never plans a process or probes a wrapper" + ); + assert!(calls + .iter() + .any(|(command, payload)| command == "build.decodeModel" + && payload["request"]["root"] == "north")); +} + +#[test] +fn effective_options_fingerprints_and_model_revisions_stay_together() { + let workspace = Workspace::new(); + let packages = Packages::new(); + let options = Options::default(); + options + .values + .lock() + .expect("options") + .systems + .entry(SYSTEM.into()) + .or_default() + .values = BTreeMap::from([ + ("profiles".into(), json!(["alpha"])), + ("offline".into(), json!(true)), + ]); + let core = Core::default(); + let mut registry = BuildRegistry::new(); + let view = registry + .refresh(&core, &packages, &options, "main", &workspace.path()) + .expect("refresh"); + let old = context(®istry, &workspace, &packages); + assert_eq!(old.options["profiles"], json!(["alpha"])); + let sync = core.execute("build.syncFingerprint", json!({"manifest":old.package.manifest,"buildSystemID":SYSTEM,"request":{"workspaceRoot":workspace.path(),"root":"north","projects":old.model["projects"].as_array().expect("projects").iter().map(|project| project["path"].clone()).collect::>(),"options":old.options}})).expect("Core fingerprint"); + assert_eq!( + instance(&view, "north")["syncDigest"], + sync["sync"]["fingerprint"] + ); + let updated = registry + .set_option( + &core, + &packages, + &options, + "main", + &workspace.path(), + &packages.input().plugin_id, + SYSTEM, + "north", + "profiles", + Some(json!([])), + ) + .expect("explicit empty profile selection"); + let new = context(®istry, &workspace, &packages); + assert!(!registry.context_is_current(&old)); + assert_eq!(new.options["profiles"], json!([])); + assert_ne!( + instance(&view, "north")["optionFingerprint"], + instance(&updated, "north")["optionFingerprint"] + ); + assert_eq!(new.model, instance(&updated, "north")["model"]); + assert!(core + .seen + .lock() + .expect("calls") + .iter() + .any(|(command, payload)| command == "build.decodeModel" + && payload["request"]["root"] == "north" + && payload["request"]["options"]["profiles"] == json!([]))); + registry + .refresh(&core, &packages, &options, "main", &workspace.path()) + .expect("reload"); + assert!( + !registry.context_is_current(&new), + "same-content reload also invalidates approvals" + ); +} + +#[test] +fn changing_a_module_during_passive_decode_marks_its_model_stale() { + let workspace = Arc::new(Workspace::new()); + let changed = Arc::new(AtomicBool::new(false)); + let core = Core { + seen: Mutex::new(vec![]), + after: Some(Arc::new({ + let workspace = workspace.clone(); + let changed = changed.clone(); + move |command, payload| { + if command == "build.decodeModel" + && payload["request"]["root"] == "north" + && !changed.swap(true, Ordering::SeqCst) + { + workspace.write("north/lib/pom.xml", &pom("new-library", "")); + } + } + })), + }; + let packages = Packages::new(); + let options = Options::default(); + let mut registry = BuildRegistry::new(); + let view = registry + .refresh(&core, &packages, &options, "main", &workspace.path()) + .expect("refresh"); + assert_eq!(instance(&view, "north")["stale"], true); + assert_eq!(instance(&view, "south")["stale"], false); + let old = context(®istry, &workspace, &packages); + let view = registry + .refresh( + &Core::default(), + &packages, + &options, + "main", + &workspace.path(), + ) + .expect("stable reload"); + assert_eq!(instance(&view, "north")["stale"], false); + assert!(instance(&view, "north")["model"]["projects"] + .as_array() + .expect("projects") + .iter() + .any(|project| project["name"] == "new-library")); + assert!(!registry.context_is_current(&old)); +} + +#[test] +fn revocation_and_same_version_replacements_during_decode_cannot_publish() { + for replacement in ["disabled", "relocated", "manifest"] { + let workspace = Workspace::new(); + let packages = Arc::new(Packages::new()); + let options = Options::default(); + let mut registry = BuildRegistry::new(); + registry + .refresh( + &Core::default(), + &*packages, + &options, + "main", + &workspace.path(), + ) + .expect("initial"); + let old = context(®istry, &workspace, &packages); + let ticket = registry.reserve_refresh("main", &workspace.path()); + let core = Core { + seen: Mutex::new(vec![]), + after: Some(Arc::new({ + let packages = packages.clone(); + move |command, _| { + if command == "build.decodeModel" { + let mut package = packages.0.lock().expect("package"); + match replacement { + "disabled" => *package = None, + "relocated" => { + package.as_mut().expect("enabled").content_identity = + "verified-installation:second".into() + } + "manifest" => { + package.as_mut().expect("enabled").manifest["displayName"] = + json!("Replaced fixture") + } + _ => unreachable!(), + } + } + } + })), + }; + let result = registry.refresh_reserved(&core, &*packages, &options, &ticket, &|| Ok(())); + assert_eq!(result.err().as_deref(), Some("plugin.build.packageChanged")); + assert!(!registry.context_is_current(&old)); + assert_eq!(options.saves.load(Ordering::SeqCst), 0); + registry.drop_plugin(&old.plugin_id); + assert!(registry.view("main").instances.is_empty()); + assert!(registry + .refresh_reserved(&Core::default(), &*packages, &options, &ticket, &|| Ok(())) + .is_err()); + } +} + +#[test] +fn same_version_sync_invalidates_model_identity_without_adopting_new_package() { + let workspace = Workspace::new(); + let packages = Packages::new(); + let options = Options::default(); + let mut registry = BuildRegistry::new(); + registry + .refresh( + &Core::default(), + &packages, + &options, + "main", + &workspace.path(), + ) + .expect("initial"); + let old = context(®istry, &workspace, &packages); + packages + .0 + .lock() + .expect("package") + .as_mut() + .expect("enabled") + .content_identity = "same-version:new-bytes".into(); + let view = registry + .sync_check( + &Core::default(), + &packages, + "main", + &workspace.path(), + &old.plugin_id, + SYSTEM, + "north", + ) + .expect("sync reports stale using current verified package"); + assert_eq!(instance(&view, "north")["stale"], true); + assert!(!registry.context_is_current(&old)); + assert_eq!( + context(®istry, &workspace, &packages).package, + old.package, + "old model retains its original decoding identity" + ); + assert!(registry + .set_option( + &Core::default(), + &packages, + &options, + "main", + &workspace.path(), + &old.plugin_id, + SYSTEM, + "north", + "offline", + Some(json!(true)) + ) + .is_err()); + assert_eq!(options.saves.load(Ordering::SeqCst), 0); + registry + .refresh( + &Core::default(), + &packages, + &options, + "main", + &workspace.path(), + ) + .expect("explicit reload"); + let new = context(®istry, &workspace, &packages); + assert_eq!(new.package, packages.input()); + assert!(!new.model_stale); +} + +#[test] +fn option_storage_changed_during_model_read_cannot_publish_or_be_overwritten() { + let workspace = Workspace::new(); + let packages = Packages::new(); + let options = Arc::new(Options::default()); + let core = Core { + seen: Mutex::new(vec![]), + after: Some(Arc::new({ + let options = options.clone(); + move |command, _| { + if command == "build.decodeModel" { + options + .values + .lock() + .expect("options") + .systems + .entry(SYSTEM.into()) + .or_default() + .values + .insert("offline".into(), json!(true)); + } + } + })), + }; + let mut registry = BuildRegistry::new(); + let ticket = registry.reserve_refresh("main", &workspace.path()); + assert_eq!( + registry + .refresh_reserved(&core, &packages, &*options, &ticket, &|| Ok(())) + .err() + .as_deref(), + Some("plugin.build.staleContext") + ); + assert!(registry.view("main").instances.is_empty()); + assert_eq!(options.saves.load(Ordering::SeqCst), 0); + assert_eq!( + options.values.lock().expect("options").systems[SYSTEM].values["offline"], + true + ); + // The consumed request cannot be replayed against the changed options. + assert!(registry + .refresh_reserved(&Core::default(), &packages, &*options, &ticket, &|| Ok(())) + .is_err()); +} + +#[test] +fn queued_initial_refresh_cannot_revive_a_closed_or_replaced_workspace() { + for action in [ + "workspace-close", + "window-close", + "new-request", + "option-change", + "plugin-change", + ] { + let workspace = Workspace::new(); + let packages = Packages::new(); + let options = Options::default(); + let mut registry = BuildRegistry::new(); + let ticket = registry.reserve_refresh("main", &workspace.path()); + match action { + "workspace-close" => registry.close_workspace("main", &workspace.path()), + "window-close" => registry.close_window("main"), + "new-request" => { + registry.reserve_refresh("main", "different-workspace"); + } + "option-change" => { + registry.suspend_execution("main"); + registry.resume_execution("main"); + } + "plugin-change" => registry.drop_plugin(&packages.input().plugin_id), + _ => unreachable!(), + } + let core = Core::default(); + assert_eq!( + registry + .refresh_reserved(&core, &packages, &options, &ticket, &|| Ok(())) + .err() + .as_deref(), + Some("plugin.build.staleContext") + ); + assert!( + core.seen.lock().expect("calls").is_empty(), + "refuse before rereading a closed workspace" + ); + assert!(registry.view("main").instances.is_empty()); + let second = registry.reserve_refresh("workspace-second", &workspace.path()); + assert!(!registry + .refresh_reserved(&core, &packages, &options, &second, &|| Ok(())) + .expect("other window remains independent") + .instances + .is_empty()); + } +} + +#[test] +fn window_checkpoint_and_failed_passive_decode_do_not_publish_a_ready_model() { + let workspace = Arc::new(Workspace::new()); + let packages = Packages::new(); + let options = Options::default(); + let live = Arc::new(AtomicBool::new(true)); + let core = Core { + seen: Mutex::new(vec![]), + after: Some(Arc::new({ + let live = live.clone(); + move |command, _| { + if command == "build.decodeModel" { + live.store(false, Ordering::SeqCst); + } + } + })), + }; + let mut registry = BuildRegistry::new(); + let ticket = registry.reserve_refresh("main", &workspace.path()); + assert!(registry + .refresh_reserved( + &core, + &packages, + &options, + &ticket, + &|| if live.load(Ordering::SeqCst) { + Ok(()) + } else { + Err("plugin.build.staleContext".into()) + } + ) + .is_err()); + assert!(registry.view("main").instances.is_empty()); + let core = Core { + seen: Mutex::new(vec![]), + after: Some(Arc::new({ + let workspace = workspace.clone(); + move |command, _| { + if command == "build.detect" { + workspace.write("north/pom.xml", ""); + } + } + })), + }; + let view = registry + .refresh(&core, &packages, &options, "main", &workspace.path()) + .expect("a failed reactor does not hide its sibling"); + assert!(instance(&view, "north")["model"].is_null()); + assert_eq!( + instance(&view, "north")["modelError"]["code"], + "plugin.build.invalidOutput" + ); + assert!(!instance(&view, "south")["model"].is_null()); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/language_import.rs b/windows/tauri/src-tauri/src/plugin_host/build/language_import.rs new file mode 100644 index 0000000000..35c6b62570 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/language_import.rs @@ -0,0 +1,259 @@ +//! Passive Java import from the live build registry. Core owns option and +//! Maven/Gradle semantics; this adapter only selects an exact instance and +//! brackets the conversion with model, window and accepted-package identity. + +use super::super::build_registry::{ + BuildCoreExecutor, BuildModelContext, BuildPackageSource, BuildRegistry, +}; +use super::*; +use serde::Serialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildLanguageImport { + pub workspace_root: String, + pub generation: u64, + pub identity: String, + pub maven_context: Option, + pub java_gradle: Option, +} + +fn relative_path(path: &str) -> Result<(), String> { + if path == "." { + return Ok(()); + } + if path.is_empty() + || path.contains('\\') + || path.contains(':') + || path.starts_with('/') + || path + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + { + return Err("plugin.build.invalidSelection".into()); + } + Ok(()) +} + +fn contains(root: &str, source: &str) -> bool { + let root = root.to_ascii_lowercase(); + let source = source.to_ascii_lowercase(); + root == "." + || source == root + || source + .strip_prefix(&root) + .is_some_and(|tail| tail.starts_with('/')) +} + +/// No invocation planning, runtime discovery or executable/version probing is +/// performed here. In particular a Wrapper is only a Core-owned input file. +pub(super) fn resolve( + registry: &BuildRegistry, + core: &dyn BuildCoreExecutor, + packages: &dyn BuildPackageSource, + window: &str, + workspace: &str, + generation: u64, + source_path: Option<&str>, + checkpoint: &dyn Fn() -> Result<(), String>, +) -> Result { + checkpoint()?; + registry.check_read_snapshot(window, workspace, generation)?; + if let Some(path) = source_path { + relative_path(path)?; + } + let view = registry.view(window); + if view.workspace_root != workspace || view.generation != generation { + return Err("plugin.build.staleContext".into()); + } + // Rejected declarations must not turn into a successful legacy fallback. + if view.issues.iter().any(|issue| issue["kind"] == "rejected") { + return Err("plugin.build.invalidPlan".into()); + } + let mut candidates: BTreeMap> = BTreeMap::new(); + for instance in &view.instances { + let context = registry.model_context( + window, + workspace, + instance["pluginID"] + .as_str() + .ok_or("plugin.build.invalidSelection")?, + instance["buildSystemID"] + .as_str() + .ok_or("plugin.build.invalidSelection")?, + instance["root"] + .as_str() + .ok_or("plugin.build.invalidSelection")?, + )?; + let system = context.package.manifest["buildSystems"] + .as_array() + .and_then(|systems| { + systems + .iter() + .find(|system| system["id"] == context.build_system_id) + }) + .ok_or("plugin.build.invalidSelection")?; + if system["languageImport"].is_null() { + continue; + } + relative_path(&context.root)?; + if source_path.is_some_and(|path| !contains(&context.root, path)) { + continue; + } + let kind = system["languageImport"]["kind"] + .as_str() + .ok_or("plugin.build.invalidPlan")?; + if !matches!(kind, "maven" | "gradle") { + return Err("plugin.build.invalidPlan".into()); + } + candidates.entry(kind.into()).or_default().push(context); + } + let mut maven_context = None; + let mut java_gradle = None; + let mut snapshots = Vec::new(); + let mut checked_contexts = Vec::new(); + for (kind, mut candidates) in candidates { + // Ancestor instances cannot displace the most specific containing + // root. Equal-specificity matches are ambiguous regardless of order. + let depth = |root: &str| { + if root == "." { + 0 + } else { + root.split('/').count() + } + }; + candidates.sort_by_key(|context| std::cmp::Reverse(depth(&context.root))); + if candidates.len() > 1 + && (source_path.is_none() || depth(&candidates[0].root) == depth(&candidates[1].root)) + { + return Err("plugin.build.invalidSelection".into()); + } + let context = candidates.remove(0); + let instance = view + .instances + .iter() + .find(|instance| { + instance["pluginID"] == context.plugin_id + && instance["buildSystemID"] == context.build_system_id + && instance["root"] == context.root + }) + .ok_or("plugin.build.staleContext")?; + if !instance["modelError"].is_null() + || context.model_stale + || (!instance["modelSource"].is_null() && context.model.is_null()) + { + return Err("plugin.build.modelReloadRequired".into()); + } + if packages.package(&context.plugin_id).as_ref() != Some(&context.package) { + return Err("plugin.build.packageChanged".into()); + } + let before = registry.model_fingerprint(core, &context)?; + if instance["syncDigest"].as_str() != Some(before.as_str()) { + return Err("plugin.build.modelReloadRequired".into()); + } + let response = core.execute("build.languageImport", json!({ + "manifest": context.package.manifest, "buildSystemID": context.build_system_id, + "request": { "workspaceRoot": workspace, "root": context.root, "options": context.options }, + }))?; + operation::core_ok(&response)?; + let import = &response["languageImport"]; + if import["kind"].as_str() != Some(kind.as_str()) { + return Err("plugin.build.invalidPlan".into()); + } + match kind.as_str() { + "maven" if import["mavenContext"].is_object() => { + maven_context = Some(import["mavenContext"].clone()) + } + "gradle" if import["javaGradle"].is_object() => { + java_gradle = Some(import["javaGradle"].clone()) + } + "maven" | "gradle" => return Err("plugin.build.invalidPlan".into()), + _ => unreachable!(), + } + checkpoint()?; + if !registry.context_is_current(&context) + || registry.model_fingerprint(core, &context)? != before + { + return Err("plugin.build.staleContext".into()); + } + if packages.package(&context.plugin_id).as_ref() != Some(&context.package) { + return Err("plugin.build.packageChanged".into()); + } + snapshots.push( + json!({ "plugin": context.plugin_id, "system": context.build_system_id, "revision": context.revision, + "root": context.root, "package": context.package.content_identity, + "manifest": context.package.manifest, "options": context.options, + "model": context.model, "fingerprint": before, "import": import }), + ); + checked_contexts.push((context, before)); + } + checkpoint()?; + registry.check_read_snapshot(window, workspace, generation)?; + // Both import kinds belong to one result. A later Core call must not + // leave an earlier instance unchecked at the publication boundary. + for (context, before) in &checked_contexts { + if !registry.context_is_current(context) + || registry.model_fingerprint(core, context)? != *before + { + return Err("plugin.build.staleContext".into()); + } + if packages.package(&context.plugin_id).as_ref() != Some(&context.package) { + return Err("plugin.build.packageChanged".into()); + } + } + let identity = format!( + "{:x}", + Sha256::digest( + serde_json::to_vec(&json!({ + "window": window, "workspace": workspace, "generation": generation, + "sourcePath": source_path, "snapshots": snapshots, + })) + .map_err(|error| error.to_string())? + ) + ); + Ok(BuildLanguageImport { + workspace_root: workspace.into(), + generation, + identity, + maven_context, + java_gradle, + }) +} + +pub(super) async fn prepare( + app: AppHandle, + label: String, + workspace_root: String, + generation: u64, + source_path: Option, + expected_identity: Option, +) -> Result { + // Reject an obsolete queued request before entering the blocking pool. + live_build_window(&app, &label)?; + app.state::() + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")? + .check_read_snapshot(&label, &workspace_root, generation)?; + let checkpoint_app = app.clone(); + run_build_task(app, move |registry, packages, _persistence| { + let result = resolve( + registry, + &HostCoreExecutor, + packages, + &label, + &workspace_root, + generation, + source_path.as_deref(), + &|| live_build_window(&checkpoint_app, &label), + )?; + if expected_identity.is_some_and(|expected| expected != result.identity) { + return Err("plugin.build.staleContext".into()); + } + Ok(result) + }) + .await +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/language_import_tests.rs b/windows/tauri/src-tauri/src/plugin_host/build/language_import_tests.rs new file mode 100644 index 0000000000..3b5de2196a --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/language_import_tests.rs @@ -0,0 +1,623 @@ +//! Real Core conversion with deterministic, passive registry fixtures. No +//! executable resolver, wrapper probe, process or network is available here. + +use super::super::build_registry::*; +use super::language_import::resolve; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +#[derive(Default)] +struct Core { + calls: Mutex>, +} +impl BuildCoreExecutor for Core { + fn execute(&self, command: &str, payload: Value) -> Result { + self.calls.lock().unwrap().push(command.into()); + super::super::scan::core_execute(command, payload) + } +} +struct Packages(Mutex>); +impl BuildPackageSource for Packages { + fn accepted_packages(&self) -> Vec { + self.0.lock().unwrap().clone() + } + fn package(&self, id: &str) -> Option { + self.0 + .lock() + .unwrap() + .iter() + .find(|package| package.plugin_id == id) + .cloned() + } +} +#[derive(Default)] +struct Options(Mutex); +impl BuildOptionsPersistence for Options { + fn load(&self, _: &str) -> Result { + Ok(self.0.lock().unwrap().clone()) + } + fn save(&self, _: &str, value: &StoredBuildOptions) -> Result<(), String> { + *self.0.lock().unwrap() = value.clone(); + Ok(()) + } +} +struct Workspace(PathBuf); +impl Workspace { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "lithe java import {}", + super::operation::next_operation_id() + )); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + fn root(&self) -> String { + self.0.to_string_lossy().into_owned() + } + fn write(&self, relative: &str, content: &str) { + let path = self.0.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + } + fn pom(&self, root: &str) { + self.write(&format!("{root}/pom.xml"), "4.0.0fixtureapp1"); + } +} +impl Drop for Workspace { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +fn package(kind: &str) -> BuildPackageInput { + let fixture = if kind == "maven" { + "build-host-integration.plugin.json" + } else { + "build-command-output-file.plugin.json" + }; + let mut manifest: Value = serde_json::from_str( + &std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../shared/contracts/plugins/v1/fixtures") + .join(fixture), + ) + .unwrap(), + ) + .unwrap(); + // The Maven fixture requests a host integration reserved to this namespace. + manifest["id"] = json!(format!("dev.lithe.fixture.import-{kind}")); + manifest["buildSystems"][0]["id"] = json!(format!("arbitrary-{kind}")); + manifest["activationEvents"] = json!([format!("onBuildSystem:arbitrary-{kind}")]); + manifest["buildSystems"][0]["languageImport"] = if kind == "maven" { + json!({"kind":"maven","options":{"profiles":"profiles","settingsFile":"settingsFile","localRepository":"localRepository"}}) + } else { + manifest["buildSystems"][0]["options"] + .as_array_mut() + .unwrap() + .push(json!({"id":"home","displayName":"Home","kind":"directory", "environment":{"GRADLE_USER_HOME":"${value}"}})); + json!({"kind":"gradle","options":{"offline":"offline","gradleUserHome":"home"}}) + }; + BuildPackageInput { + plugin_id: manifest["id"].as_str().unwrap().into(), + version: "0.1.0".into(), + content_identity: "accepted-original-bytes".into(), + manifest, + } +} +fn refresh( + registry: &mut BuildRegistry, + core: &Core, + packages: &Packages, + options: &Options, + workspace: &Workspace, +) { + let view = registry + .refresh(core, packages, options, "main", &workspace.root()) + .unwrap(); + assert!( + view.issues.iter().all(|issue| issue["kind"] != "rejected"), + "invalid test fixture: {:?}", + view.issues + ); +} + +#[test] +fn exact_nested_instance_uses_core_effective_options_without_executing_the_wrapper() { + let workspace = Workspace::new(); + workspace.pom("north"); + workspace.pom("south"); + workspace.write( + "north/mvnw.cmd", + "@echo off\r\necho should never execute > wrapper-ran\r\n", + ); + let packages = Packages(Mutex::new(vec![package("maven")])); + let options = Options::default(); + options.0.lock().unwrap().systems.insert( + "arbitrary-maven".into(), + StoredSystemOptions { + values: BTreeMap::from([ + ("profiles".into(), json!(["dev"])), + ("settingsFile".into(), json!("C:/fixture/settings.xml")), + ]), + empty_markers: BTreeMap::from([( + "south".into(), + ["profiles".into()].into_iter().collect(), + )]), + }, + ); + let core = Core::default(); + let mut registry = BuildRegistry::new(); + refresh(&mut registry, &core, &packages, &options, &workspace); + let generation = registry.view("main").generation; + let north = resolve( + ®istry, + &core, + &packages, + "main", + &workspace.root(), + generation, + Some("north/src/App.java"), + &|| Ok(()), + ) + .unwrap(); + assert_eq!( + north.maven_context.as_ref().unwrap()["reactorPath"], + "north" + ); + assert_eq!( + north.maven_context.as_ref().unwrap()["profiles"], + json!(["dev"]) + ); + assert_eq!( + north.maven_context.as_ref().unwrap()["settingsPath"], + "C:/fixture/settings.xml" + ); + let south = resolve( + ®istry, + &core, + &packages, + "main", + &workspace.root(), + generation, + Some("south/src/App.java"), + &|| Ok(()), + ) + .unwrap(); + assert_eq!(south.maven_context.as_ref().unwrap()["profiles"], json!([])); + assert_ne!(north.identity, south.identity); + assert!(resolve( + ®istry, + &core, + &packages, + "main", + &workspace.root(), + generation, + None, + &|| Ok(()) + ) + .is_err()); + assert!(!workspace.0.join("north/wrapper-ran").exists()); + assert!(core.calls.lock().unwrap().iter().all(|command| matches!( + command.as_str(), + "build.detect" + | "build.resolveOptions" + | "build.decodeModel" + | "build.syncFingerprint" + | "build.languageImport" + ))); +} + +#[test] +fn option_edit_same_version_replacement_close_and_switch_never_borrow_an_old_snapshot() { + let workspace = Workspace::new(); + workspace.pom("."); + let packages = Packages(Mutex::new(vec![package("maven")])); + let core = Core::default(); + let options = Options::default(); + let mut registry = BuildRegistry::new(); + refresh(&mut registry, &core, &packages, &options, &workspace); + let generation = registry.view("main").generation; + let first = resolve( + ®istry, + &core, + &packages, + "main", + &workspace.root(), + generation, + Some("App.java"), + &|| Ok(()), + ) + .unwrap(); + registry + .set_option( + &core, + &packages, + &options, + "main", + &workspace.root(), + "dev.lithe.fixture.import-maven", + "arbitrary-maven", + ".", + "profiles", + Some(json!(["changed"])), + ) + .unwrap(); + let changed = resolve( + ®istry, + &core, + &packages, + "main", + &workspace.root(), + registry.view("main").generation, + Some("App.java"), + &|| Ok(()), + ) + .unwrap(); + assert_ne!(first.identity, changed.identity); + packages.0.lock().unwrap()[0].content_identity = "accepted-same-version-replacement".into(); + assert!(resolve( + ®istry, + &core, + &packages, + "main", + &workspace.root(), + registry.view("main").generation, + Some("App.java"), + &|| Ok(()) + ) + .unwrap_err() + .contains("packageChanged")); + registry.close_workspace("main", &workspace.root()); + assert!(resolve( + ®istry, + &core, + &packages, + "main", + &workspace.root(), + generation, + Some("App.java"), + &|| Ok(()) + ) + .is_err()); + refresh(&mut registry, &core, &packages, &options, &workspace); + assert!(resolve( + ®istry, + &core, + &packages, + "main", + "C:/other", + registry.view("main").generation, + Some("App.java"), + &|| Ok(()) + ) + .is_err()); + assert!(resolve( + ®istry, + &core, + &packages, + "another-window", + &workspace.root(), + generation, + Some("App.java"), + &|| Ok(()) + ) + .is_err()); +} + +#[test] +fn missing_command_model_is_an_error_until_the_approved_model_is_loaded() { + let workspace = Workspace::new(); + workspace.write("build.gradle", "plugins {}\n"); + workspace.write( + "gradle/wrapper/gradle-wrapper.properties", + "distributionUrl=https://example.invalid/gradle.zip\n", + ); + let packages = Packages(Mutex::new(vec![package("gradle")])); + let core = Core::default(); + let options = Options::default(); + options.0.lock().unwrap().systems.insert( + "arbitrary-gradle".into(), + StoredSystemOptions { + values: BTreeMap::from([("home".into(), json!("C:/fixture/gradle-cache"))]), + empty_markers: BTreeMap::new(), + }, + ); + let mut registry = BuildRegistry::new(); + refresh(&mut registry, &core, &packages, &options, &workspace); + let generation = registry.view("main").generation; + assert!(resolve( + ®istry, + &core, + &packages, + "main", + &workspace.root(), + generation, + Some("src/App.java"), + &|| Ok(()) + ) + .unwrap_err() + .contains("modelReloadRequired")); + let context = registry + .model_context( + "main", + &workspace.root(), + "dev.lithe.fixture.import-gradle", + "arbitrary-gradle", + ".", + ) + .unwrap(); + let fingerprint = registry.model_fingerprint(&core, &context).unwrap(); + registry + .apply_command_model( + &core, + &context, + json!({"projects":[],"targets":[],"tasks":[]}), + &fingerprint, + ) + .unwrap(); + registry + .set_option( + &core, + &packages, + &options, + "main", + &workspace.root(), + "dev.lithe.fixture.import-gradle", + "arbitrary-gradle", + ".", + "offline", + Some(json!(true)), + ) + .unwrap(); + // Option edits stale the command model. A controlled approved reload is + // necessary; the import path itself never tries to run the model command. + let context = registry + .model_context( + "main", + &workspace.root(), + "dev.lithe.fixture.import-gradle", + "arbitrary-gradle", + ".", + ) + .unwrap(); + let fingerprint = registry.model_fingerprint(&core, &context).unwrap(); + registry + .apply_command_model( + &core, + &context, + json!({"projects":[],"targets":[],"tasks":[]}), + &fingerprint, + ) + .unwrap(); + let result = resolve( + ®istry, + &core, + &packages, + "main", + &workspace.root(), + registry.view("main").generation, + Some("src/App.java"), + &|| Ok(()), + ) + .unwrap(); + assert_eq!( + result.java_gradle.unwrap(), + json!({"wrapperPresent":true,"offline":true,"userHome":"C:/fixture/gradle-cache"}) + ); + assert!(result.maven_context.is_none()); + // Even identical model data is a new native snapshot after a reload. + // Revision identity must not depend on whether the visible JSON changed. + let context = registry + .model_context( + "main", + &workspace.root(), + "dev.lithe.fixture.import-gradle", + "arbitrary-gradle", + ".", + ) + .unwrap(); + let fingerprint = registry.model_fingerprint(&core, &context).unwrap(); + registry + .apply_command_model(&core, &context, context.model.clone(), &fingerprint) + .unwrap(); + let reloaded = resolve( + ®istry, + &core, + &packages, + "main", + &workspace.root(), + registry.view("main").generation, + Some("src/App.java"), + &|| Ok(()), + ) + .unwrap(); + assert_ne!(result.identity, reloaded.identity); +} + +#[test] +fn unowned_workspace_returns_no_import_but_invalid_declarations_do_not_fall_back() { + let workspace = Workspace::new(); + workspace.pom("."); + let core = Core::default(); + let options = Options::default(); + let mut registry = BuildRegistry::new(); + let empty = Packages(Mutex::new(vec![])); + refresh(&mut registry, &core, &empty, &options, &workspace); + let result = resolve( + ®istry, + &core, + &empty, + "main", + &workspace.root(), + registry.view("main").generation, + None, + &|| Ok(()), + ) + .unwrap(); + assert!(result.maven_context.is_none() && result.java_gradle.is_none()); + let mut invalid = package("maven"); + invalid.manifest["buildSystems"][0]["languageImport"]["options"]["profiles"] = + json!("missing-option"); + let packages = Packages(Mutex::new(vec![invalid])); + registry + .refresh(&core, &packages, &options, "main", &workspace.root()) + .unwrap(); + assert!(resolve( + ®istry, + &core, + &packages, + "main", + &workspace.root(), + registry.view("main").generation, + None, + &|| Ok(()) + ) + .is_err()); +} + +#[test] +fn workspace_file_changes_and_window_disposal_during_core_conversion_are_rejected() { + let workspace = Workspace::new(); + workspace.pom("."); + let core = Core::default(); + let options = Options::default(); + let mut registry = BuildRegistry::new(); + let packages = Packages(Mutex::new(vec![package("maven")])); + refresh(&mut registry, &core, &packages, &options, &workspace); + workspace.write("pom.xml", "changed after model"); + assert!(resolve( + ®istry, + &core, + &packages, + "main", + &workspace.root(), + registry.view("main").generation, + Some("App.java"), + &|| Ok(()) + ) + .is_err()); + assert!(resolve( + ®istry, + &core, + &packages, + "main", + &workspace.root(), + registry.view("main").generation, + Some("../outside.java"), + &|| Ok(()) + ) + .is_err()); + assert!(resolve( + ®istry, + &core, + &packages, + "main", + &workspace.root(), + registry.view("main").generation, + None, + &|| Err("plugin.build.staleContext".into()) + ) + .is_err()); +} + +#[test] +fn nested_roots_are_exact_and_equal_depth_import_owners_are_ambiguous() { + let workspace = Workspace::new(); + workspace.pom("."); + // A one-character directory must still be deeper than the "." sentinel. + workspace.pom("a"); + let mut first = package("maven"); + first.manifest["buildSystems"][0]["detection"]["nesting"] = json!("separate"); + let packages = Packages(Mutex::new(vec![first.clone()])); + let core = Core::default(); + let options = Options::default(); + let mut registry = BuildRegistry::new(); + refresh(&mut registry, &core, &packages, &options, &workspace); + let value = resolve( + ®istry, + &core, + &packages, + "main", + &workspace.root(), + registry.view("main").generation, + Some("a/src/App.java"), + &|| Ok(()), + ) + .unwrap(); + assert_eq!(value.maven_context.unwrap()["reactorPath"], "a"); + let mut second = first.clone(); + second.plugin_id = "dev.lithe.fixture.second-import".into(); + second.manifest["id"] = json!(second.plugin_id); + second.manifest["buildSystems"][0]["id"] = json!("second-system"); + second.manifest["activationEvents"] = json!(["onBuildSystem:second-system"]); + packages.0.lock().unwrap().push(second); + refresh(&mut registry, &core, &packages, &options, &workspace); + assert!(resolve( + ®istry, + &core, + &packages, + "main", + &workspace.root(), + registry.view("main").generation, + Some("a/src/App.java"), + &|| Ok(()) + ) + .is_err()); +} + +#[test] +fn a_reserved_refresh_cannot_claim_that_no_declarative_owner_exists() { + let workspace = Workspace::new(); + let packages = Packages(Mutex::new(vec![])); + let core = Core::default(); + let options = Options::default(); + let mut registry = BuildRegistry::new(); + refresh(&mut registry, &core, &packages, &options, &workspace); + let _ticket = registry.reserve_refresh("main", &workspace.root()); + assert!(resolve( + ®istry, + &core, + &packages, + "main", + &workspace.root(), + registry.view("main").generation, + None, + &|| Ok(()) + ) + .unwrap_err() + .contains("staleContext")); +} + +#[test] +fn trust_is_rechecked_after_core_even_when_the_package_version_did_not_change() { + let workspace = Workspace::new(); + workspace.pom("."); + let packages = Packages(Mutex::new(vec![package("maven")])); + let core = Core::default(); + let options = Options::default(); + let mut registry = BuildRegistry::new(); + refresh(&mut registry, &core, &packages, &options, &workspace); + struct ChangedCore<'a>(&'a Packages); + impl BuildCoreExecutor for ChangedCore<'_> { + fn execute(&self, command: &str, payload: Value) -> Result { + let result = super::super::scan::core_execute(command, payload); + if command == "build.languageImport" { + self.0 .0.lock().unwrap()[0].content_identity = "same-version-replaced".into(); + } + result + } + } + assert!(resolve( + ®istry, + &ChangedCore(&packages), + &packages, + "main", + &workspace.root(), + registry.view("main").generation, + Some("App.java"), + &|| Ok(()) + ) + .unwrap_err() + .contains("packageChanged")); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/operation.rs b/windows/tauri/src-tauri/src/plugin_host/build/operation.rs new file mode 100644 index 0000000000..d5fd2c6fc9 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/operation.rs @@ -0,0 +1,625 @@ +//! Explicit build operation ownership and command-model orchestration. +//! Core owns plans and decoding; this module owns cancellation, frozen inputs, +//! budgets and the point at which an operation is allowed to publish a result. + +use super::super::build_registry::{BuildCoreExecutor, BuildModelContext}; +use super::super::build_resolver::{ + BuildResolutionStep, ResolvedBuildTool, VersionProbeOutcome, WindowsBuildToolResolver, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; +use std::sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, +}; +use std::time::{Duration, Instant}; +use tokio::sync::Notify; + +pub(super) const MODEL_TIMEOUT: Duration = Duration::from_secs(120); +pub(super) const PROBE_TIMEOUT: Duration = Duration::from_secs(5); +pub(super) const MODEL_OUTPUT_LIMIT: usize = 32 * 1024 * 1024; +pub(super) const PROBE_OUTPUT_LIMIT: usize = 64 * 1024; +const REVIEW_TIMEOUT: Duration = Duration::from_secs(120); +static NEXT_OPERATION: AtomicU64 = AtomicU64::new(1); + +pub(super) fn next_operation_id() -> String { + format!( + "build-{}-{}", + std::process::id(), + NEXT_OPERATION.fetch_add(1, Ordering::Relaxed) + ) +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ModelCommandPlan { + pub arguments: Vec, + pub environment: BTreeMap, + pub working_directory: String, + pub output: String, + pub executes_project_code: bool, + pub may_use_network: bool, +} + +/// Host-side review. Accepting it authorizes only this operation, package and +/// workspace; it does not permanently mark the workspace or package trusted. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildModelReview { + pub operation_id: String, + pub plugin_id: String, + pub build_system_id: String, + pub workspace_root: String, + pub root: String, + pub generation: u64, + pub executes_project_code: bool, + pub may_use_network: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, +} + +pub(super) struct ModelOperation { + pub id: String, + pub context: BuildModelContext, + pub plan: ModelCommandPlan, + pub invocation: Value, + pub resolver: WindowsBuildToolResolver, + pub output_file: PathBuf, + pub working_directory: PathBuf, + pub run: Option, + pub dependencies: Option, + pub provider: Option, + pub settings_reload: Mutex>, + created: Instant, + cancelled: AtomicBool, + running: AtomicBool, + finished: AtomicBool, + changed: Notify, + publication: Mutex<()>, + retained_cleanup: Mutex>>, + provider_session_cleanup: AtomicBool, +} + +impl ModelOperation { + pub fn new( + id: String, + context: BuildModelContext, + plan: ModelCommandPlan, + invocation: Value, + resolver: WindowsBuildToolResolver, + output_file: PathBuf, + working_directory: PathBuf, + ) -> Self { + Self { + id, + context, + plan, + invocation, + resolver, + output_file, + working_directory, + run: None, + dependencies: None, + provider: None, + settings_reload: Mutex::new(None), + created: Instant::now(), + cancelled: AtomicBool::new(false), + running: AtomicBool::new(false), + finished: AtomicBool::new(false), + changed: Notify::new(), + publication: Mutex::new(()), + retained_cleanup: Mutex::new(None), + provider_session_cleanup: AtomicBool::new(false), + } + } + + pub fn review(&self) -> BuildModelReview { + // Every project candidate can execute repository code, regardless of + // its manifest flag. Consent must precede runtime and version probes. + let project_candidate = self.invocation["executables"] + .as_array() + .into_iter() + .flatten() + .chain( + self.invocation["runtimeDependencies"] + .as_array() + .into_iter() + .flatten() + .flat_map(|runtime| runtime["executables"].as_array().into_iter().flatten()), + ) + .any(|candidate| candidate["kind"] == "projectFile"); + BuildModelReview { + operation_id: self.id.clone(), + plugin_id: self.context.plugin_id.clone(), + build_system_id: self.context.build_system_id.clone(), + workspace_root: self.context.workspace_root.clone(), + root: self.context.root.clone(), + generation: self.context.generation, + executes_project_code: self.plan.executes_project_code || project_candidate, + may_use_network: self.plan.may_use_network, + provider: self.provider.as_ref().map(|provider| { + let mut review = provider.review.clone(); + review.invocation_plan = Some(self.invocation.clone()); + review + }), + } + } + + pub fn cancelled(&self) -> bool { + self.cancelled.load(Ordering::Acquire) + } + pub fn finished(&self) -> bool { + self.finished.load(Ordering::Acquire) + } + + pub fn cancel(&self) { + let _publication = self.publication.lock().expect("build publication mutex"); + self.cancelled.store(true, Ordering::Release); + if let Some(provider) = &self.provider { + provider.cancel_review(); + } + self.changed.notify_waiters(); + // A pending review owns no processes. A claimed operation is finished + // only by its RAII owner after native tree cleanup has returned. + if !self.running.load(Ordering::Acquire) { + if let Some(events) = self.events() { + // No native owner exists for a cancelled pending review. + let _ = events.terminal(&super::output::BuildRunTerminal { + kind: "cancelled".into(), + exit_code: None, + message: Some("plugin.build.cancelled".into()), + }); + } + self.finish(); + } + } + + pub fn publish(&self, write: impl FnOnce() -> Result) -> Result { + let _publication = self + .publication + .lock() + .map_err(|_| "build publication mutex poisoned")?; + if self.cancelled() { + return Err("plugin.build.cancelled".into()); + } + write() + } + + /// The native owner calls this only after tree cleanup. Order its last + /// checkpoint and terminal event with cancellation, so a cancel accepted + /// before publication cannot be followed by a successful terminal. + pub fn complete_run( + &self, + mut terminal: super::output::BuildRunTerminal, + checkpoint: impl FnOnce() -> Result<(), String>, + ) -> Result { + let _publication = self + .publication + .lock() + .map_err(|_| "build publication mutex poisoned")?; + let checked = if self.cancelled() { + Err("plugin.build.cancelled".into()) + } else { + checkpoint() + }; + if let Err(error) = checked { + terminal.kind = if super::run::error_terminal(&error).kind == "cancelled" { + "cancelled" + } else if terminal.kind == "failedToStart" { + "failedToStart" + } else { + "failed" + } + .into(); + terminal.message = Some(error); + } + self.events() + .ok_or("plugin.build.invalidPlan")? + .terminal(&terminal)?; + Ok(terminal) + } + + fn events(&self) -> Option<&super::output::RunEvents> { + self.run.as_ref().map(|run| &run.events).or_else(|| { + self.dependencies + .as_ref() + .map(|dependencies| &dependencies.events) + }) + } + + pub fn start_permit(&self) -> Result, String> { + let permit = self + .publication + .lock() + .map_err(|_| "build publication mutex poisoned")?; + if self.cancelled() { + return Err("plugin.build.cancelled".into()); + } + Ok(permit) + } + + fn finish(&self) { + // Fail closed when native cleanup cannot prove tree exit. Retaining + // its lease blocks package mutation and keeps this instance busy. + if self + .retained_cleanup + .lock() + .expect("build cleanup lease") + .is_some() + { + return; + } + self.finished.store(true, Ordering::Release); + self.changed.notify_waiters(); + } + + pub fn retain_failed_cleanup(&self, lease: impl Send + 'static) { + *self.retained_cleanup.lock().expect("build cleanup lease") = Some(Box::new(lease)); + } + + pub fn finish_provider_cleanup(&self) { + if self.provider.is_none() || !self.provider_session_cleanup.swap(false, Ordering::AcqRel) { + return; + } + let retained = self + .retained_cleanup + .lock() + .expect("build cleanup lease") + .take() + .is_some(); + if retained { + self.finish(); + } + } + + pub fn retain_provider_session_cleanup(&self) { + self.retain_failed_cleanup(()); + self.provider_session_cleanup.store(true, Ordering::Release); + } + + pub async fn wait_finished(&self) -> Result<(), String> { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + let notified = self.changed.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if self.finished() { + return; + } + notified.await; + } + }) + .await + .map_err(|_| "plugin.build.cleanupTimeout: process owner has not drained".into()) + } + + pub async fn wait_cancelled(&self) { + loop { + let changed = self.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + if self.cancelled() { + return; + } + changed.await; + } + } +} + +#[derive(Default)] +pub(super) struct BuildOperations { + entries: Mutex>>, +} + +impl BuildOperations { + pub fn insert(&self, operation: ModelOperation) -> Result { + let mut entries = self + .entries + .lock() + .map_err(|_| "build operation mutex poisoned")?; + entries.retain(|_, old| { + if !old.running.load(Ordering::Acquire) && old.created.elapsed() >= REVIEW_TIMEOUT { + old.cancel(); + } + !old.finished() + }); + if entries.values().any(|old| { + old.context.window_label == operation.context.window_label + && old.context.workspace_root == operation.context.workspace_root + && old.context.plugin_id == operation.context.plugin_id + && old.context.build_system_id == operation.context.build_system_id + && old.context.root == operation.context.root + }) { + return Err("plugin.build.busy".into()); + } + if entries.len() >= 64 { + return Err("plugin.build.busy: operation budget exhausted".into()); + } + let review = operation.review(); + entries.insert(operation.id.clone(), Arc::new(operation)); + Ok(review) + } + + pub fn get(&self, window: &str, id: &str) -> Result, String> { + self.entries + .lock() + .map_err(|_| "build operation mutex poisoned")? + .get(id) + .filter(|operation| operation.context.window_label == window) + .cloned() + .ok_or_else(|| "plugin.build.unknownOperation".into()) + } + + pub fn claim(&self, window: &str, id: &str) -> Result { + let operation = self.get(window, id)?; + // Linearize claim with pending-review cancellation. A cancellation + // that wins cannot emit terminal and then have this owner start work. + let permit = operation + .publication + .lock() + .map_err(|_| "build publication mutex poisoned")?; + if operation.created.elapsed() >= REVIEW_TIMEOUT { + drop(permit); + operation.cancel(); + return Err("plugin.build.cancelled".into()); + } + if operation.cancelled() || operation.finished() { + return Err("plugin.build.cancelled".into()); + } + if operation + .running + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err("plugin.build.busy".into()); + } + // Cancellation may race with the claim. The executor checks it before + // any side effect; waiters must now wait for this owner's Drop. + operation.finished.store(false, Ordering::Release); + drop(permit); + Ok(RunningModelOperation(operation)) + } + + pub fn cancel_matching( + &self, + predicate: impl Fn(&ModelOperation) -> bool, + ) -> Vec> { + let entries = self.matching(predicate); + for operation in &entries { + operation.cancel(); + } + entries + } + + pub fn matching( + &self, + predicate: impl Fn(&ModelOperation) -> bool, + ) -> Vec> { + let entries = self.entries.lock().expect("build operation mutex"); + entries + .values() + .filter(|operation| predicate(operation)) + .cloned() + .collect() + } +} + +pub(super) struct RunningModelOperation(pub Arc); +impl Drop for RunningModelOperation { + fn drop(&mut self) { + self.0.finish(); + } +} + +/// Production checkpoints verify live package bytes; cheap cancellation polls +/// inspect lifecycle/context only, without repeatedly hashing package files. +pub(super) trait ModelExecutionGuard { + fn checkpoint(&self) -> Result<(), String>; + fn cancelled(&self) -> bool; + /// Keep cancellation ordered with the shared executor's synchronous + /// spawn. The permit is released as soon as its `started` hook fires. + fn start_permit(&self) -> Result, String> { + self.checkpoint()?; + Ok(Box::new(())) + } +} + +pub(super) trait StartPermit {} +impl StartPermit for T {} + +#[derive(Debug, Clone)] +pub(super) struct BuildProcessPlan { + pub executable: PathBuf, + pub arguments: Vec, + pub environment: BTreeMap, + pub directory: PathBuf, + pub timeout: Duration, + pub output_limit: usize, + pub output_file: Option, +} + +pub(super) struct BuildProcessOutput { + pub stdout: Vec, + pub stderr: Vec, +} +pub(super) struct ExecutedModel { + pub model: Value, + pub stderr: String, +} +pub(super) struct BuildRunProcessOutput { + pub exit_code: Option, + pub started: bool, + pub failure: Option, + pub started_at_millis: Option, +} +pub(super) trait BuildProcessExecutor { + fn run( + &self, + plan: &BuildProcessPlan, + guard: &dyn ModelExecutionGuard, + ) -> Result; + fn run_command( + &self, + plan: &BuildProcessPlan, + guard: &dyn ModelExecutionGuard, + output: &mut dyn FnMut(lithe_git_host::Stream, &[u8]) -> Result<(), String>, + ) -> BuildRunProcessOutput { + let started_at_millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|time| u64::try_from(time.as_millis()).ok()); + match self.run(plan, guard) { + Ok(result) => { + let failure = output(lithe_git_host::Stream::Stdout, &result.stdout) + .and_then(|_| output(lithe_git_host::Stream::Stderr, &result.stderr)) + .err(); + BuildRunProcessOutput { + exit_code: Some(0), + started: true, + failure, + started_at_millis, + } + } + Err(error) => BuildRunProcessOutput { + exit_code: None, + started: false, + failure: Some(error), + started_at_millis: None, + }, + } + } +} + +/// Shared pipeline for real native execution and deterministic executor tests. +/// No operation mutates the registry until the caller's final checkpoint. +pub(super) fn resolve_tool( + operation: &ModelOperation, + core: &dyn BuildCoreExecutor, + executor: &dyn BuildProcessExecutor, + guard: &dyn ModelExecutionGuard, +) -> Result { + guard.checkpoint()?; // consent returned; before runtime preparation + let resolver = operation + .resolver + .clone() + .with_overrides(operation.plan.environment.clone()); + let mut resolution = resolver + .start( + &operation.invocation, + &PathBuf::from(&operation.context.workspace_root), + ) + .map_err(|error| error.to_string())?; + guard.checkpoint()?; // runtime preparation finished + let mut outcome = None; + let tool = loop { + guard.checkpoint()?; + match resolver + .advance(&mut resolution, outcome.take()) + .map_err(|error| format!("{}: {error}", error.code()))? + { + BuildResolutionStep::Resolved(tool) => break tool, + BuildResolutionStep::Probe(probe) => { + let result = executor.run( + &BuildProcessPlan { + executable: probe.executable, + arguments: probe.arguments.clone(), + environment: probe.environment.clone(), + directory: operation.working_directory.clone(), + timeout: PROBE_TIMEOUT, + output_limit: PROBE_OUTPUT_LIMIT, + output_file: None, + }, + guard, + ); + // Cleanup failure takes precedence over a concurrent revoke: + // the host must retain the NativeLease until exit is proven. + if let Err(message) = &result { + if message.starts_with("plugin.build.cleanupFailed") { + return Err(message.clone()); + } + } + guard.checkpoint()?; + outcome = Some(match result { + Ok(output) => { + let mut combined = String::from_utf8_lossy(&output.stdout).into_owned(); + combined.push('\n'); + combined.push_str(&String::from_utf8_lossy(&output.stderr)); + let mut request = json!({"output": combined}); + if let Some(runtime) = &probe.runtime { + request["runtime"] = json!(runtime); + } + let response = core.execute("build.checkToolVersion", json!({ + "manifest": operation.context.package.manifest, "buildSystemID": operation.context.build_system_id, + "request": request, + }))?; + core_ok(&response)?; + VersionProbeOutcome::from_core(&response) + .map_err(|error| error.to_string())? + } + Err(message) => VersionProbeOutcome::Failed { message }, + }); + } + } + }; + Ok(tool) +} + +pub(super) fn execute_model( + operation: &ModelOperation, + core: &dyn BuildCoreExecutor, + executor: &dyn BuildProcessExecutor, + guard: &dyn ModelExecutionGuard, +) -> Result { + let tool = resolve_tool(operation, core, executor, guard)?; + guard.checkpoint()?; // immediately before model command start + let output = executor.run( + &BuildProcessPlan { + executable: tool.executable, + arguments: operation.plan.arguments.clone(), + environment: tool.environment, + directory: operation.working_directory.clone(), + timeout: MODEL_TIMEOUT, + output_limit: MODEL_OUTPUT_LIMIT, + output_file: (operation.plan.output == "outputFile") + .then(|| operation.output_file.clone()), + }, + guard, + )?; + guard.checkpoint()?; + let stdout = if operation.plan.output == "stdout" { + String::from_utf8(output.stdout) + .map_err(|_| "plugin.build.invalidOutput: stdout is not UTF-8")? + } else { + String::new() + }; + let response = core.execute("build.decodeModel", json!({ + "manifest": operation.context.package.manifest, "buildSystemID": operation.context.build_system_id, + "request": {"workspaceRoot": operation.context.workspace_root, "root": operation.context.root, + "options": operation.context.options, "output": stdout, + "outputFile": (operation.plan.output == "outputFile").then(|| operation.output_file.to_string_lossy().into_owned())}, + }))?; + core_ok(&response)?; + guard.checkpoint()?; + Ok(ExecutedModel { + model: response["model"].clone(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} + +pub(super) fn core_ok(response: &Value) -> Result<(), String> { + if response["status"] == "ok" { + Ok(()) + } else { + Err(format!( + "{}: {}", + response["error"]["code"] + .as_str() + .unwrap_or("plugin.build.invalidPlan"), + response["error"]["message"] + .as_str() + .unwrap_or("Core rejected the build operation") + )) + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/output.rs b/windows/tauri/src-tauri/src/plugin_host/build/output.rs new file mode 100644 index 0000000000..bcd84ddad4 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/output.rs @@ -0,0 +1,171 @@ +//! Window-scoped ordered events matching output/build-output-types.ts. The +//! native operation is the sole lifecycle owner; no replay buffer or second +//! run registry is maintained here. + +use super::super::build_registry::BuildModelContext; +use serde::Serialize; +use serde_json::{json, Value}; +use std::sync::{Arc, Mutex}; + +pub(super) const MAX_RUN_OUTPUT: usize = 8 * 1024 * 1024; +const MAX_EVENTS: usize = 16_384; +const MAX_DECODED_EVENT: usize = 8 * 1024 * 1024; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BuildRunTerminal { + pub kind: String, + pub exit_code: Option, + /// Stable host category or bounded tool diagnostic, never raw environment. + pub message: Option, +} + +pub(super) trait BuildOutputSink: Send + Sync { + fn emit(&self, window_label: &str, event: Value) -> Result<(), String>; +} + +#[derive(Default)] +struct EventState { + bytes: usize, + events: usize, + terminal: bool, +} + +pub(super) struct RunEvents { + window: String, + identity: Value, + sink: Arc, + state: Mutex, +} + +impl RunEvents { + pub fn new( + context: &BuildModelContext, + operation_id: &str, + sink: Arc, + ) -> Self { + Self { + window: context.window_label.clone(), + identity: json!({ + "operationId": operation_id, "pluginId": context.plugin_id, + "buildSystemId": context.build_system_id, "root": context.root, + "workspaceRoot": context.workspace_root, "generation": context.generation, + }), + sink, + state: Mutex::new(EventState::default()), + } + } + + fn event(&self, payload: Value) -> Value { + let mut event = self.identity.clone(); + event + .as_object_mut() + .expect("routing identity") + .extend(payload.as_object().expect("event").clone()); + event + } + + pub fn chunk(&self, stream: &str, text: &str) -> Result<(), String> { + if text.is_empty() { + return Ok(()); + } + let mut state = self + .state + .lock() + .map_err(|_| "plugin.build.outputUnavailable")?; + if state.terminal { + return Err("plugin.build.staleContext".into()); + } + if state.bytes.saturating_add(text.len()) > MAX_RUN_OUTPUT || state.events >= MAX_EVENTS { + return Err("plugin.build.outputLimit".into()); + } + state.bytes += text.len(); + state.events += 1; + self.sink.emit( + &self.window, + self.event(json!({"type":"chunk", "stream":stream, "text":text})), + ) + } + + pub fn decoded(&self, kind: &str, key: &str, value: Value) -> Result<(), String> { + if serde_json::to_vec(&value) + .map_err(|_| "plugin.build.invalidOutput")? + .len() + > MAX_DECODED_EVENT + { + return Err("plugin.build.outputLimit".into()); + } + let state = self + .state + .lock() + .map_err(|_| "plugin.build.outputUnavailable")?; + if state.terminal { + return Err("plugin.build.staleContext".into()); + } + let mut payload = json!({"type":kind}); + payload[key] = value; + self.sink.emit(&self.window, self.event(payload)) + } + + /// Called only after the executor has drained its complete tree, or for + /// a cancelled review that has never acquired a process. Failed cleanup + /// deliberately leaves the operation occupied and emits no false terminal. + pub fn terminal(&self, terminal: &BuildRunTerminal) -> Result<(), String> { + let mut state = self + .state + .lock() + .map_err(|_| "plugin.build.outputUnavailable")?; + if state.terminal { + return Ok(()); + } + state.terminal = true; + self.sink.emit( + &self.window, + self.event(json!({"type":"terminal", "terminal":terminal})), + ) + } +} + +/// Incremental stream decoding keeps UTF-8 code points intact across native +/// pipe chunks. The pending tail is at most three bytes for valid UTF-8. +#[derive(Default)] +pub(super) struct Utf8Stream { + pending: Vec, +} +impl Utf8Stream { + pub fn push(&mut self, bytes: &[u8]) -> String { + self.pending.extend_from_slice(bytes); + let mut text = String::new(); + loop { + match std::str::from_utf8(&self.pending) { + Ok(valid) => { + text.push_str(valid); + self.pending.clear(); + break; + } + Err(error) => { + let end = error.valid_up_to(); + text.push_str( + std::str::from_utf8(&self.pending[..end]).expect("validated prefix"), + ); + match error.error_len() { + Some(length) => { + text.push('\u{fffd}'); + self.pending.drain(..end + length); + } + None => { + self.pending.drain(..end); + break; + } + } + } + } + } + text + } + pub fn finish(&mut self) -> String { + let text = String::from_utf8_lossy(&self.pending).into_owned(); + self.pending.clear(); + text + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/process.rs b/windows/tauri/src-tauri/src/plugin_host/build/process.rs new file mode 100644 index 0000000000..cb636be4d6 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/process.rs @@ -0,0 +1,315 @@ +//! Bounded native process adapter. Job Object ownership, pipe draining and +//! tree termination stay in lithe_git_host, shared with the launch path. + +use super::operation::{ + BuildProcessExecutor, BuildProcessOutput, BuildProcessPlan, BuildRunProcessOutput, + ModelExecutionGuard, MODEL_OUTPUT_LIMIT, +}; +use std::cell::{Cell, RefCell}; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +const FAILURE_STREAM_BYTES: usize = 4096; + +/// Preserve actionable process diagnostics using the existing shared secret +/// redaction policy. Input, retained text and regex work are independently +/// bounded; a failure never returns an environment snapshot or raw output. +pub(super) fn process_failure(reason: &str, stdout: &[u8], stderr: &[u8]) -> String { + fn detail(bytes: &[u8]) -> String { + // Keep lookahead for a quoted secret spanning the display boundary. + let captured = String::from_utf8_lossy(&bytes[..bytes.len().min(16 * 1024)]); + let redacted = lithe_core::diagnostics::logging::sanitize_log_text(&captured); + let mut end = redacted.len().min(FAILURE_STREAM_BYTES); + while !redacted.is_char_boundary(end) { + end -= 1; + } + let mut value = redacted[..end].to_string(); + if end < redacted.len() || bytes.len() > 16 * 1024 { + value.push_str("…[truncated]"); + } + value + } + let mut message = format!("plugin.build.processFailed: {}", detail(reason.as_bytes())); + for (name, bytes) in [("stderr", stderr), ("stdout", stdout)] { + if !bytes.is_empty() { + message.push_str(&format!("\n{name}: {}", detail(bytes))); + } + } + message +} + +pub(super) struct NativeBuildExecutor; + +/// `lithe_git_host` invokes its started hook after the child is resumed. +/// Freeze report freshness before entering that runner, so even an immediate +/// child report belongs to this run. The hook only records successful spawn. +pub(super) struct ProcessStart { + before_spawn_millis: Option, + started: Cell, +} +impl ProcessStart { + pub fn capture(clock: impl FnOnce() -> Option) -> Self { + Self { + before_spawn_millis: clock(), + started: Cell::new(false), + } + } + pub fn mark_started(&self) { + self.started.set(true); + } + pub fn started(&self) -> bool { + self.started.get() + } + pub fn started_at_millis(&self) -> Option { + self.started().then_some(self.before_spawn_millis).flatten() + } +} + +impl BuildProcessExecutor for NativeBuildExecutor { + fn run( + &self, + plan: &BuildProcessPlan, + guard: &dyn ModelExecutionGuard, + ) -> Result { + let (output, result) = capture_process(plan, guard, &mut |_, _| Ok(())); + if let Some(error) = result.failure { + return Err(error); + } + if result.exit_code != Some(0) { + return Err(process_failure( + "unsuccessful exit", + &output.stdout, + &output.stderr, + )); + } + Ok(output) + } + + fn run_command( + &self, + plan: &BuildProcessPlan, + guard: &dyn ModelExecutionGuard, + output: &mut dyn FnMut(lithe_git_host::Stream, &[u8]) -> Result<(), String>, + ) -> BuildRunProcessOutput { + capture_process(plan, guard, output).1 + } +} + +/// Both model probes and explicit runs use the same frozen argv/environment, +/// budgets, native Job Object and cleanup/error precedence. +fn capture_process( + plan: &BuildProcessPlan, + guard: &dyn ModelExecutionGuard, + output: &mut dyn FnMut(lithe_git_host::Stream, &[u8]) -> Result<(), String>, +) -> (BuildProcessOutput, BuildRunProcessOutput) { + let empty = |error: String| { + ( + BuildProcessOutput { + stdout: vec![], + stderr: vec![], + }, + BuildRunProcessOutput { + exit_code: None, + started: false, + failure: Some(error), + started_at_millis: None, + }, + ) + }; + if let Err(error) = guard.checkpoint() { + return empty(error); + } + if !plan.executable.is_absolute() || !plan.directory.is_absolute() { + return empty("plugin.build.invalidPlan".into()); + } + let mut command = std::process::Command::new(&plan.executable); + command + .args(&plan.arguments) + .current_dir(&plan.directory) + .env_clear() + .envs(&plan.environment); + let deadline = Instant::now() + plan.timeout; + let stdout_bytes = Cell::new(0usize); + let stderr_bytes = Cell::new(0usize); + let output_file_invalid = Cell::new(false); + let exceeded = + || stdout_bytes.get() > plan.output_limit || stderr_bytes.get() > plan.output_limit; + let start_permit = match guard.start_permit() { + Ok(permit) => RefCell::new(Some(permit)), + Err(error) => return empty(error), + }; + let start = ProcessStart::capture(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|time| u64::try_from(time.as_millis()).ok()) + }); + let stream_failure = RefCell::new(None::); + let outcome = lithe_git_host::run( + &mut command, + None, + || { + if let Some(path) = &plan.output_file { + output_file_invalid.set(validate_output_file(path).is_err()); + } + exceeded() + || stream_failure.borrow().is_some() + || output_file_invalid.get() + || Instant::now() >= deadline + || guard.cancelled() + }, + || { + start.mark_started(); + drop(start_permit.borrow_mut().take()); + }, + |stream, bytes| { + let count = match stream { + lithe_git_host::Stream::Stdout => &stdout_bytes, + lithe_git_host::Stream::Stderr => &stderr_bytes, + }; + count.set(count.get().saturating_add(bytes.len())); + if !exceeded() && stream_failure.borrow().is_none() { + if let Err(error) = output(stream, bytes) { + *stream_failure.borrow_mut() = Some(error); + } + } + }, + ); + // Spawn failure has no started hook. Never publish success merely because + // the parent process exited. + drop(start_permit); + let failure = if matches!( + outcome.failure.as_ref(), + Some(lithe_git_host::Failure::Cleanup) + ) { + Some("plugin.build.cleanupFailed".into()) + } else if let Some(error) = stream_failure.into_inner() { + Some(error) + } else if exceeded() + || output_file_invalid.get() + || matches!( + outcome.failure.as_ref(), + Some(lithe_git_host::Failure::OutputLimit) + ) + { + Some("plugin.build.outputLimit".into()) + } else if Instant::now() >= deadline { + Some("plugin.build.timeout".into()) + } else if let Err(error) = guard.checkpoint() { + Some(error) + } else if let Some(failure) = outcome.failure { + Some(process_failure( + &format!("{failure:?}"), + &outcome.stdout, + &outcome.stderr, + )) + } else if let Some(path) = &plan.output_file { + validate_output_file(path).err() + } else { + None + }; + let result = BuildRunProcessOutput { + exit_code: outcome.status.and_then(|status| status.code()), + started: start.started(), + failure, + started_at_millis: start.started_at_millis(), + }; + ( + BuildProcessOutput { + stdout: outcome.stdout, + stderr: outcome.stderr, + }, + result, + ) +} + +pub(super) fn validate_output_file(path: &Path) -> Result<(), String> { + let metadata = std::fs::symlink_metadata(path) + .map_err(|_| "plugin.build.invalidOutput: output file is missing")?; + #[cfg(windows)] + let linked = { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & 0x0400 != 0 + }; + #[cfg(not(windows))] + let linked = metadata.file_type().is_symlink(); + if linked || !metadata.is_file() { + return Err("plugin.build.invalidOutput: output must be a regular file".into()); + } + if metadata.len() > MODEL_OUTPUT_LIMIT as u64 { + return Err("plugin.build.outputLimit".into()); + } + Ok(()) +} + +fn validate_output_parent(path: &Path) -> Result<(), String> { + if !path.is_absolute() { + return Err("plugin.build.invalidOutput: output path must be absolute".into()); + } + for directory in path + .parent() + .ok_or("invalid model output path")? + .ancestors() + { + let metadata = std::fs::symlink_metadata(directory).map_err(|error| error.to_string())?; + #[cfg(windows)] + let linked = { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & 0x0400 != 0 + }; + #[cfg(not(windows))] + let linked = metadata.file_type().is_symlink(); + if linked || !metadata.is_dir() { + return Err("plugin.build.invalidOutput: output directory crosses a link".into()); + } + } + Ok(()) +} + +/// A fresh file inside the already-owned plugin state directory, never in a +/// package or installation. There is no reusable cache or new resource root. +/// One operation removes its own file after tree cleanup, including failures; +/// a crash leaves only per-user plugin state, removed with plugin uninstall. +pub(super) struct ModelOutputFile { + path: PathBuf, +} +impl ModelOutputFile { + pub fn create(path: &Path) -> Result { + if !path.is_absolute() { + return Err("plugin.build.invalidOutput: output path must be absolute".into()); + } + let parent = path.parent().ok_or("invalid model output path")?; + // Validate existing ancestors before create_dir_all can traverse a + // redirected plugin-state path, then verify the newly made chain. + for directory in parent.ancestors() { + match std::fs::symlink_metadata(directory) { + Ok(_) => { + validate_output_parent(&directory.join("model-output"))?; + break; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error.to_string()), + } + } + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + // The storage adapter supplies this path. Reject redirected state + // before creating a file so no write can enter distribution resources. + validate_output_parent(path)?; + let file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|error| format!("model output allocation failed: {error}"))?; + drop(file); + Ok(Self { path: path.into() }) + } +} +impl Drop for ModelOutputFile { + fn drop(&mut self) { + if let Err(error) = std::fs::remove_file(&self.path) { + if error.kind() != std::io::ErrorKind::NotFound { + eprintln!("model output cleanup failed: {error}"); + } + } + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/provider.rs b/windows/tauri/src-tauri/src/plugin_host/build/provider.rs new file mode 100644 index 0000000000..70bacf9048 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/provider.rs @@ -0,0 +1,425 @@ +//! Provider routing for the existing build operation owner. Core produces the +//! request plan and decodes the reply; the native session owns all transport. + +use super::super::provider as sessions; +use super::host::{verified_identity, HostGuard}; +use super::operation::*; +use super::{BuildHostState, HostCoreExecutor}; +use serde_json::{json, Value}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Manager}; + +pub(super) struct ProviderOperation { + pub plan: Value, + pub review: sessions::ProviderReview, + plugin_directory: std::path::PathBuf, + workspace_state_directory: std::path::PathBuf, + app: AppHandle, +} +impl ProviderOperation { + pub fn cancel_review(&self) { + sessions::discard_review(&self.app, &self.review.context.window, self.review.token); + } +} +impl Drop for ProviderOperation { + fn drop(&mut self) { + self.cancel_review(); + } +} + +pub(super) async fn authorize_shared( + app: AppHandle, + window: String, + id: String, +) -> Result<(), String> { + blocking(move || { + let operation = app.state::().operations.get(&window, &id)?; + let provider = operation + .provider + .as_ref() + .ok_or("plugin.build.invalidPlan")?; + let guard = HostGuard { + app: &app, + operation: &operation, + deadline: Instant::now() + Duration::from_secs(10), + }; + guard.checkpoint()?; + if operation.dependencies.is_some() { + super::dependencies::DependencyGuard { + base: HostGuard { + app: &app, + operation: &operation, + deadline: Instant::now() + Duration::from_secs(10), + }, + } + .checkpoint()?; + } + sessions::authorize_shared_language(&app, &window, provider.review.token, || { + guard.checkpoint() + }) + .map_err(failure) + }) + .await +} + +/// Called on the existing preparation worker. Resolving programs is passive; +/// version probes, initialization and project code wait for native confirm. +pub(super) fn prepare( + app: &AppHandle, + context: &super::super::build_registry::BuildModelContext, + plan: Value, + runtimes: std::collections::BTreeMap< + String, + super::super::build_resolver::RuntimeExecutableInput, + >, +) -> Result { + let window = app + .get_webview_window(&context.window_label) + .ok_or("plugin.build.staleContext")?; + tauri::async_runtime::block_on(sessions::set_workspace( + app, + &window, + context.workspace_root.clone(), + context.generation, + )) + .map_err(failure)?; + let package = verified_identity(app, context)?; + let state = super::store::plugin_state_directory(app, &context.plugin_id)?; + let review = sessions::prepare( + app, + &window, + context.workspace_root.clone(), + context.generation, + context.plugin_id.clone(), + context.build_system_id.clone(), + context.root.clone(), + runtimes, + ) + .map_err(failure)?; + Ok(ProviderOperation { + plan, + review, + app: app.clone(), + plugin_directory: package.root, + workspace_state_directory: state + .join(super::store::workspace_state_key(&context.workspace_root)), + }) +} + +pub(super) fn command(plan: &Value) -> Result { + if plan["provider"]["method"].as_str().is_none() || plan["provider"]["params"].is_null() { + return Err("plugin.build.invalidPlan".into()); + } + Ok(ModelCommandPlan { + arguments: Vec::new(), + environment: Default::default(), + working_directory: plan["root"] + .as_str() + .ok_or("plugin.build.invalidPlan")? + .into(), + output: "provider".into(), + executes_project_code: plan["executesProjectCode"].as_bool().unwrap_or(true), + may_use_network: plan["mayUseNetwork"].as_bool().unwrap_or(true), + }) +} + +fn failure(error: sessions::ProviderError) -> String { + // Native diagnostic detail stays out of user-visible text; the frontend + // translates these stable keys through the shared dictionary. + match error.code { + "provider.cancelled" => "plugin.build.cancelled", + "provider.notNegotiated" | "provider.notReady" => "plugin.build.providerNotNegotiated", + "provider.stopFailed" => "plugin.build.cleanupFailed", + "provider.requestTimeout" | "provider.initializeTimeout" | "provider.transportTimeout" => { + "plugin.build.timeout" + } + "provider.identityChanged" | "provider.invalidInstance" => "plugin.build.staleContext", + _ => "plugin.build.providerFailed", + } + .into() +} + +pub(super) fn decode( + core: &dyn super::super::build_registry::BuildCoreExecutor, + operation: &ModelOperation, + result: Value, + project: Option<&str>, +) -> Result { + let dependencies = operation.dependencies.is_some(); + let response=core.execute(if dependencies {"build.decodeDependencies"} else {"build.decodeModel"},json!({ + "manifest":operation.context.package.manifest,"buildSystemID":operation.context.build_system_id, + "request":{"workspaceRoot":operation.context.workspace_root,"root":operation.context.root, + "options":operation.context.options,"project":project,"result":result}, + }))?; + core_ok(&response)?; + Ok(response[if dependencies { + "dependencies" + } else { + "model" + }] + .clone()) +} + +/// Only failed automatic discovery permits the Provider's optional tool hint +/// to be omitted. Explicit selections and invalid/unsafe plans stay terminal. +pub(super) fn tool_hint( + resolved: Result, +) -> Result, String> { + match resolved { + Ok(tool) => Ok(Some(tool.executable)), + Err(error) if error.starts_with("plugin.build.toolUnavailable:") => { + eprintln!("Build Provider will resolve its own tool: {error}"); + Ok(None) + } + Err(error) => Err(error), + } +} + +async fn request(app: &AppHandle, operation: &Arc) -> Result { + let prepared = operation + .provider + .as_ref() + .ok_or("plugin.build.invalidPlan")?; + let window = app + .get_webview_window(&operation.context.window_label) + .ok_or("plugin.build.staleContext")?; + let plan = { + let app = app.clone(); + let operation = operation.clone(); + blocking(move || { + let guard = HostGuard { + app: &app, + operation: &operation, + deadline: Instant::now() + Duration::from_secs(180), + }; + guard.checkpoint()?; + let lease = app + .state::() + .acquire(&operation.context.plugin_id)?; + let resolved = resolve_tool( + &operation, + &HostCoreExecutor, + &super::process::NativeBuildExecutor, + &guard, + ); + if let Err(error) = &resolved { + if error.starts_with("plugin.build.cleanupFailed") { + operation.retain_failed_cleanup(lease); + return Err(error.clone()); + } + } + guard.checkpoint()?; + let tool = tool_hint(resolved)?; + replan(&HostCoreExecutor, &operation, tool.as_deref()) + }) + .await? + }; + let future = async { + let binding = sessions::confirm(app, &window, prepared.review.token) + .await + .map_err(failure)?; + binding + .request(app, &window, plan, 180000) + .await + .map_err(failure) + }; + let result = tokio::select! { biased; + _=operation.wait_cancelled()=>Err("plugin.build.cancelled".into()), + result=tokio::time::timeout(Duration::from_secs(200),future)=>result.unwrap_or_else(|_|Err("plugin.build.timeout".into())), + }; + if result.is_err() { + if let Err(error) = + sessions::drain_initialization(app, window.label(), prepared.review.token).await + { + operation.retain_provider_session_cleanup(); + return Err(failure(error)); + } + } + if result + .as_ref() + .err() + .is_some_and(|error| error.starts_with("plugin.build.cleanupFailed")) + { + operation.retain_provider_session_cleanup(); + } + result +} + +/// Optional host tool hints are produced by Core too. Failed discovery omits +/// the hint and lets the Provider use its own upstream tool discovery. +pub(super) fn replan( + core: &dyn super::super::build_registry::BuildCoreExecutor, + operation: &ModelOperation, + tool: Option<&std::path::Path>, +) -> Result { + let dependencies = operation.dependencies.as_ref(); + let project = dependencies + .and_then(|prepared| prepared.project_id.as_ref()) + .and_then(|id| { + operation.context.model["projects"] + .as_array() + .and_then(|projects| { + projects + .iter() + .find(|project| project["id"].as_str() == Some(id.as_str())) + }) + }) + .cloned(); + let original = operation.provider.as_ref().map(|provider| &provider.plan); + let response=core.execute(if dependencies.is_some(){"build.dependencyPlan"}else{"build.modelPlan"},json!({ + "manifest":operation.context.package.manifest,"buildSystemID":operation.context.build_system_id, + "request":{"root":operation.context.root,"options":operation.context.options,"project":project, + "pluginDirectory":operation.provider.as_ref().map(|provider|&provider.plugin_directory), + "workspaceStateDirectory":operation.provider.as_ref().map(|provider|&provider.workspace_state_directory), + "refresh":original.is_some_and(|plan|plan["provider"]["params"]["refresh"] == true), + "tool":tool.map(|path|json!({"executable":path.to_string_lossy()}))}, + }))?; + core_ok(&response)?; + if !response["plan"]["provider"].is_object() { + return Err("plugin.build.invalidPlan".into()); + } + Ok(response["plan"].clone()) +} + +async fn blocking( + task: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tauri::async_runtime::spawn_blocking(task) + .await + .map_err(|_| "plugin.build.processFailed".to_string())? +} + +pub(super) async fn load_model( + app: AppHandle, + running: RunningModelOperation, +) -> Result { + let operation = running.0.clone(); + let before = { + let app = app.clone(); + let operation = operation.clone(); + blocking(move || { + let guard = HostGuard { + app: &app, + operation: &operation, + deadline: Instant::now() + Duration::from_secs(210), + }; + guard.checkpoint()?; + let fingerprint = app + .state::() + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")? + .model_fingerprint(&HostCoreExecutor, &operation.context)?; + Ok(fingerprint) + }) + .await? + }; + let result = request(&app, &operation).await?; + blocking(move || { + let operation = &running.0; + let guard = HostGuard { + app: &app, + operation, + deadline: Instant::now() + Duration::from_secs(10), + }; + guard.checkpoint()?; + verified_identity(&app, &operation.context)?; + let model = decode(&HostCoreExecutor, operation, result, None)?; + let view = operation.publish(|| { + guard.checkpoint()?; + app.state::() + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")? + .apply_command_model(&HostCoreExecutor, &operation.context, model, &before) + })?; + Ok(super::host::BuildModelLoadResult { + operation_id: operation.id.clone(), + view, + stderr: String::new(), + }) + }) + .await +} + +pub(super) async fn load_dependencies( + app: AppHandle, + running: RunningModelOperation, +) -> Result { + let operation = running.0.clone(); + let checked = { + let app = app.clone(); + let operation = operation.clone(); + blocking(move || { + let guard = super::dependencies::DependencyGuard { + base: HostGuard { + app: &app, + operation: &operation, + deadline: Instant::now() + Duration::from_secs(210), + }, + }; + guard.checkpoint() + }) + .await + }; + let loaded = match checked { + Ok(()) => request(&app, &operation).await, + Err(error) => Err(error), + }; + if loaded + .as_ref() + .err() + .is_some_and(|error| error.starts_with("plugin.build.cleanupFailed")) + { + return Err(loaded.err().unwrap()); + } + blocking(move || { + let operation = &running.0; + let guard = super::dependencies::DependencyGuard { + base: HostGuard { + app: &app, + operation, + deadline: Instant::now() + Duration::from_secs(10), + }, + }; + let loaded = loaded.and_then(|reply| { + guard.checkpoint()?; + let decoded = decode( + &HostCoreExecutor, + operation, + reply, + operation + .dependencies + .as_ref() + .and_then(|dependencies| dependencies.project_path.as_deref()), + )?; + if !decoded["dependencies"].is_array() { + return Err("plugin.build.invalidOutput".into()); + } + Ok(decoded) + }); + let terminal = operation.complete_run( + match &loaded { + Ok(_) => super::output::BuildRunTerminal { + kind: "succeeded".into(), + exit_code: None, + message: None, + }, + Err(error) => super::run::error_terminal(error), + }, + || guard.checkpoint(), + )?; + let decoded = loaded.ok().filter(|_| terminal.kind == "succeeded"); + Ok(super::dependencies::BuildDependenciesResult { + review: super::dependencies::review(operation)?, + terminal, + truncated: decoded + .as_ref() + .is_some_and(|tree| tree["truncated"] == true), + dependency_tree: decoded.clone(), + dependencies: decoded.map(|tree| tree["dependencies"].clone()), + }) + }) + .await +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/provider_tests.rs b/windows/tauri/src-tauri/src/plugin_host/build/provider_tests.rs new file mode 100644 index 0000000000..d37d1aada1 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/provider_tests.rs @@ -0,0 +1,261 @@ +//! Real Core Provider plans and decoders through the native build route. +use super::super::build_registry::{BuildCoreExecutor, BuildModelContext, BuildPackageInput}; +use super::super::build_resolver::RuntimeExecutableInput; +use super::super::build_resolver::WindowsBuildToolResolver; +use super::operation::*; +use super::provider::*; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; + +struct Core; +impl BuildCoreExecutor for Core { + fn execute(&self, command: &str, payload: Value) -> Result { + super::super::scan::core_execute(command, payload) + } +} +struct Workspace(PathBuf); +impl Workspace { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "lithe-provider-route-{}-{}", + std::process::id(), + next_operation_id() + )); + std::fs::create_dir_all(root.join("app")).unwrap(); + Self(root) + } +} +impl Drop for Workspace { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).unwrap(); + } +} +struct Sink; +impl super::output::BuildOutputSink for Sink { + fn emit(&self, _: &str, _: Value) -> Result<(), String> { + Ok(()) + } +} + +fn manifest(shared: bool) -> Value { + serde_json::from_str(if shared {include_str!(concat!(env!("CARGO_MANIFEST_DIR"),"/../../../shared/contracts/plugins/v1/fixtures/build-provider-language-server.plugin.json"))} + else {include_str!(concat!(env!("CARGO_MANIFEST_DIR"),"/../../../shared/contracts/plugins/v1/fixtures/build-provider.plugin.json"))}).unwrap() +} +fn context(workspace: &Workspace, manifest: Value) -> BuildModelContext { + BuildModelContext { + window_label: "main".into(), + workspace_root: workspace.0.to_string_lossy().into_owned(), + generation: 9, + plugin_id: manifest["id"].as_str().unwrap().into(), + build_system_id: manifest["buildSystems"][0]["id"].as_str().unwrap().into(), + root: ".".into(), + package: BuildPackageInput { + plugin_id: manifest["id"].as_str().unwrap().into(), + version: "0.1.0".into(), + manifest, + content_identity: "whole-package-digest".into(), + }, + options: BTreeMap::new(), + model: Value::Null, + model_stale: false, + revision: 1, + } +} + +#[test] +fn both_provider_sources_plan_and_decode_real_models_through_the_build_route() { + let workspace = Workspace::new(); + for shared in [false, true] { + let context = context(&workspace, manifest(shared)); + let planned=Core.execute("build.modelPlan",json!({"manifest":context.package.manifest,"buildSystemID":context.build_system_id,"request":{"root":"."}})).unwrap(); + core_ok(&planned).unwrap(); + let plan = command(&planned["plan"]).unwrap(); + assert_eq!(plan.output, "provider"); + assert!(plan.arguments.is_empty()); + assert_eq!(planned["plan"]["provider"]["method"], "lithe/build/model"); + let operation = ModelOperation::new( + next_operation_id(), + context, + plan, + json!({}), + WindowsBuildToolResolver::with_environment(BTreeMap::new(), BTreeMap::new()), + workspace.0.join("unused"), + workspace.0.clone(), + ); + let hinted = replan(&Core, &operation, Some(&workspace.0.join("tool.exe"))).unwrap(); + assert_eq!( + hinted["provider"]["params"]["tool"]["executable"], + workspace.0.join("tool.exe").to_string_lossy().as_ref() + ); + assert!( + replan(&Core, &operation, None).unwrap()["provider"]["params"] + .get("tool") + .is_none() + ); + let model=decode(&Core,&operation,json!({"format":"litheBuildModel","version":1,"projects":[{"path":".","name":"fixture"}],"tasks":[]}),None).unwrap(); + assert_eq!(model["projects"][0]["name"], "fixture"); + assert!(decode( + &Core, + &operation, + json!({"format":"litheBuildModel","version":99}), + None + ) + .is_err()); + } +} + +#[test] +fn provider_dependencies_keep_scope_and_pass_raw_reply_to_core_decoder() { + let workspace = Workspace::new(); + for shared in [false, true] { + let context = context(&workspace, manifest(shared)); + let (plan, prepared) = super::dependencies::plan( + &Core, + &context, + &super::dependencies::BuildDependenciesSelection { project_id: None }, + &workspace.0, + &workspace.0, + &workspace.0.join("unused"), + "provider-dependencies", + "fingerprint".into(), + Arc::new(Sink), + ) + .unwrap(); + assert_eq!( + prepared.provider_plan.as_ref().unwrap()["provider"]["method"], + "lithe/build/dependencies" + ); + let mut operation = ModelOperation::new( + next_operation_id(), + context, + plan, + json!({}), + WindowsBuildToolResolver::with_environment(BTreeMap::new(), BTreeMap::new()), + workspace.0.join("unused"), + workspace.0.clone(), + ); + operation.dependencies = Some(prepared); + let result=decode(&Core,&operation,json!({"format":"litheDependencies","version":1,"dependencies":[{"name":"fixture-dependency","version":"1.0","children":[]}]}),None).unwrap(); + assert_eq!(result["dependencies"][0]["name"], "fixture-dependency"); + assert!(decode( + &Core, + &operation, + json!({"format":"litheDependencies","version":99,"dependencies":[]}), + None + ) + .is_err()); + } +} + +struct NoProcess; +impl BuildProcessExecutor for NoProcess { + fn run( + &self, + _: &BuildProcessPlan, + _: &dyn ModelExecutionGuard, + ) -> Result { + panic!("Missing or invalid paths must not run a version probe"); + } +} +struct Current; +impl ModelExecutionGuard for Current { + fn checkpoint(&self) -> Result<(), String> { + Ok(()) + } + fn cancelled(&self) -> bool { + false + } +} + +#[test] +fn provider_tool_fallback_keeps_explicit_tool_and_runtime_failures_terminal() { + let workspace = Workspace::new(); + for (configured_tool, runtime, explicit_runtime, expected_code) in [ + (true, false, false, "plugin.build.configuredToolUnavailable"), + // Runtime is resolved before the user's tool. That earlier failure must + // not silently drop the later explicit tool selection. + (true, true, false, "plugin.build.configuredToolUnavailable"), + (false, true, true, "plugin.build.configuredToolUnavailable"), + (false, true, false, "plugin.build.toolUnavailable"), + (false, false, false, "plugin.build.toolUnavailable"), + ] { + let mut manifest: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/contracts/plugins/v1/fixtures/build-tool-path.plugin.json" + ))) + .unwrap(); + if runtime { + manifest["buildSystems"][0]["invocation"]["runtimeDependencies"] = + json!([{ "id":"java", "minimumVersion":"17" }]); + } + let context = context(&workspace, manifest); + let response = Core.execute("build.invocationPlan", json!({ + "manifest": context.package.manifest, "buildSystemID": context.build_system_id, + "request": {"root":".", "platform":"windows", "settings":{ + "toolPath": if configured_tool { workspace.0.join("missing-tool.exe").to_string_lossy().into_owned() } else { String::new() } + }}, + })).unwrap(); + core_ok(&response).unwrap(); + let runtimes = if explicit_runtime { + BTreeMap::from([( + "java".into(), + RuntimeExecutableInput { + executable: workspace.0.join("missing-runtime.exe"), + environment: BTreeMap::new(), + explicit: true, + }, + )]) + } else { + BTreeMap::new() + }; + let operation = ModelOperation::new( + next_operation_id(), + context, + ModelCommandPlan { + arguments: vec![], + environment: BTreeMap::new(), + working_directory: ".".into(), + output: "provider".into(), + executes_project_code: true, + may_use_network: true, + }, + response["invocation"].clone(), + WindowsBuildToolResolver::with_environment(BTreeMap::new(), runtimes), + workspace.0.join("unused"), + workspace.0.clone(), + ); + let resolved = resolve_tool(&operation, &Core, &NoProcess, &Current); + assert!( + resolved.as_ref().err().unwrap().starts_with(expected_code), + "{:?}", + resolved.as_ref().err() + ); + let hint = tool_hint(resolved); + if expected_code == "plugin.build.toolUnavailable" { + assert!( + hint.unwrap().is_none(), + "Only automatic discovery can omit the hint" + ); + } else { + assert!( + hint.unwrap_err().starts_with(expected_code), + "Explicit selection must abort the Provider request" + ); + } + } +} + +#[test] +fn provider_tool_fallback_does_not_swallow_cleanup_or_unsafe_plan_failures() { + for error in [ + "plugin.build.cleanupFailed: live process", + "plugin.build.projectPathEscape: outside workspace", + "plugin.build.invalidPlan: malformed", + "plugin.build.cancelled", + "plugin.build.timeout", + ] { + assert_eq!(tool_hint(Err(error.into())).unwrap_err(), error); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/run.rs b/windows/tauri/src-tauri/src/plugin_host/build/run.rs new file mode 100644 index 0000000000..e0aa4f511e --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/run.rs @@ -0,0 +1,377 @@ +//! Declared task/test selection and execution. Requests name native model IDs; +//! Core alone expands argv, chooses report globs and decodes tool output. + +use super::super::build_registry::{BuildCoreExecutor, BuildModelContext}; +use super::operation::*; +use super::output::*; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum BuildRunKind { + Task, + Test, +} + +/// Only selection IDs and declared user arguments cross the IPC boundary. +/// Executables, entity properties, cwd, env, reports and permissions never do. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BuildRunSelection { + pub kind: BuildRunKind, + pub task_id: Option, + pub project_id: Option, + pub target_id: Option, + pub model_task_id: Option, + pub suite: Option, + pub case: Option, + #[serde(default)] + pub user_arguments: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct RunCommandPlan { + pub arguments: Vec, + pub environment: BTreeMap, + pub working_directory: String, + pub execution: String, + #[serde(default)] + pub results: Value, +} + +pub(super) struct PreparedRun { + pub kind: BuildRunKind, + pub plan: RunCommandPlan, + pub title: String, + pub decode_request: Value, + pub diagnostics: bool, + pub events: RunEvents, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildRunReview { + #[serde(flatten)] + pub operation: BuildModelReview, + pub title: String, + pub kind: String, +} + +fn entity(model: &Value, collection: &str, id: &Option) -> Result, String> { + id.as_ref() + .map(|id| { + model[collection] + .as_array() + .and_then(|items| { + items + .iter() + .find(|item| item["id"].as_str() == Some(id.as_str())) + }) + .cloned() + .ok_or_else(|| "plugin.build.invalidSelection".into()) + }) + .transpose() +} + +/// Pure selection/plan construction; package/state paths are native inputs. +pub(super) fn prepare_run_plan( + core: &dyn BuildCoreExecutor, + context: &BuildModelContext, + selection: &BuildRunSelection, + plugin_directory: &Path, + state_directory: &Path, + id: &str, + sink: Arc, +) -> Result { + let system = context.package.manifest["buildSystems"] + .as_array() + .and_then(|systems| { + systems + .iter() + .find(|system| system["id"] == context.build_system_id) + }) + .ok_or("plugin.build.invalidSelection")?; + // A model-derived selection must not use an old project/target/task. A + // workspace test with reports also needs every current model project: + // report globs are project-relative, so missing projects can hide failures. + // Workspace commands that do not depend on a model remain available. + let needs_model = selection.project_id.is_some() + || selection.target_id.is_some() + || selection.model_task_id.is_some() + || (selection.kind == BuildRunKind::Test + && !system["test"]["results"].is_null() + && selection.project_id.is_none()); + if needs_model && !system["model"].is_null() && (context.model.is_null() || context.model_stale) + { + return Err("plugin.build.modelReloadRequired".into()); + } + let project = entity(&context.model, "projects", &selection.project_id)?; + let target = entity(&context.model, "targets", &selection.target_id)?; + let model_task = entity(&context.model, "tasks", &selection.model_task_id)?; + for child in [&target, &model_task].into_iter().flatten() { + if project + .as_ref() + .is_none_or(|project| child["projectID"] != project["id"]) + { + return Err("plugin.build.invalidSelection".into()); + } + } + let projects: Vec<_> = context.model["projects"] + .as_array() + .into_iter() + .flatten() + .filter_map(|project| project["path"].as_str()) + .collect(); + let mut request = json!({"root":context.root, "options":context.options, + "pluginDirectory":plugin_directory, "workspaceStateDirectory":state_directory, + "project":project, "target":target, "userArguments":selection.user_arguments}); + let (command, title) = match selection.kind { + BuildRunKind::Task => { + if selection.suite.is_some() || selection.case.is_some() { + return Err("plugin.build.invalidSelection".into()); + } + let task_id = selection + .task_id + .as_ref() + .ok_or("plugin.build.invalidSelection")?; + let task = system["tasks"] + .as_array() + .and_then(|tasks| { + tasks + .iter() + .find(|task| task["id"].as_str() == Some(task_id.as_str())) + }) + .ok_or("plugin.build.invalidSelection")?; + if model_task + .as_ref() + .is_some_and(|model_task| model_task["template"].as_str() != Some(task_id.as_str())) + { + return Err("plugin.build.invalidSelection".into()); + } + request["task"] = json!(task_id); + request["modelTask"] = json!(model_task); + ( + "build.taskPlan", + task["displayName"].as_str().unwrap_or(task_id).to_string(), + ) + } + BuildRunKind::Test => { + if selection.task_id.is_some() || selection.model_task_id.is_some() { + return Err("plugin.build.invalidSelection".into()); + } + request["suite"] = json!(selection.suite); + request["case"] = json!(selection.case); + request["projects"] = json!(projects); + ("build.testPlan", context.build_system_id.clone()) + } + }; + let response = core.execute( + command, + json!({"manifest":context.package.manifest, + "buildSystemID":context.build_system_id, "request":request}), + )?; + core_ok(&response)?; + let plan: RunCommandPlan = + serde_json::from_value(response["plan"].clone()).map_err(|_| "plugin.build.invalidPlan")?; + // Long-running service/application plans require Run ownership and are not + // allowed to become an unbounded background task in this batch. + if plan.execution != "task" || plan.working_directory != context.root { + return Err("plugin.build.unavailable".into()); + } + let decode_request = json!({"workspaceRoot":context.workspace_root, "root":context.root, + "project":project.as_ref().map(|project| &project["path"]), "projects":projects, "suite":selection.suite}); + Ok(PreparedRun { + kind: selection.kind.clone(), + plan, + title, + decode_request, + diagnostics: !system["diagnostics"].is_null(), + events: RunEvents::new(context, id, sink), + }) +} + +pub(super) fn run_review(operation: &ModelOperation) -> Result { + let run = operation.run.as_ref().ok_or("plugin.build.invalidPlan")?; + Ok(BuildRunReview { + operation: operation.review(), + title: run.title.clone(), + kind: match run.kind { + BuildRunKind::Task => "task", + BuildRunKind::Test => "test", + } + .into(), + }) +} + +pub(super) fn error_terminal(error: &str) -> BuildRunTerminal { + let cancelled = [ + "plugin.build.cancelled", + "plugin.build.staleContext", + "plugin.build.packageChanged", + ] + .iter() + .any(|prefix| error.starts_with(prefix)); + BuildRunTerminal { + kind: if cancelled { + "cancelled" + } else { + "failedToStart" + } + .into(), + exit_code: None, + message: Some(error.to_string()), + } +} + +/// Returns one terminal only after native cleanup and Core decoding. Failure +/// to confirm cleanup is an error, not a terminal claiming the process ended. +pub(super) fn execute_run( + operation: &ModelOperation, + core: &dyn BuildCoreExecutor, + executor: &dyn BuildProcessExecutor, + guard: &dyn ModelExecutionGuard, +) -> Result { + let run = operation.run.as_ref().ok_or("plugin.build.invalidPlan")?; + guard.checkpoint()?; + let tool = resolve_tool(operation, core, executor, guard)?; + guard.checkpoint()?; + let mut stdout = Utf8Stream::default(); + let mut stderr = Utf8Stream::default(); + let mut diagnostic_stdout = String::new(); + let mut diagnostic_stderr = String::new(); + let mut bytes = 0usize; + let mut on_output = |stream: lithe_git_host::Stream, chunk: &[u8]| -> Result<(), String> { + bytes = bytes.saturating_add(chunk.len()); + if bytes > MAX_RUN_OUTPUT { + return Err("plugin.build.outputLimit".into()); + } + let (decoder, text, name) = match stream { + lithe_git_host::Stream::Stdout => (&mut stdout, &mut diagnostic_stdout, "stdout"), + lithe_git_host::Stream::Stderr => (&mut stderr, &mut diagnostic_stderr, "stderr"), + }; + let decoded = decoder.push(chunk); + text.push_str(&decoded); + operation.publish(|| { + if guard.cancelled() { + return Err("plugin.build.cancelled".into()); + } + run.events.chunk(name, &decoded) + }) + }; + let process = executor.run_command( + &BuildProcessPlan { + executable: tool.executable, + arguments: run.plan.arguments.clone(), + environment: tool.environment, + directory: operation.working_directory.clone(), + timeout: Duration::from_secs(600), + output_limit: MAX_RUN_OUTPUT, + output_file: None, + }, + guard, + &mut on_output, + ); + drop(on_output); + if process + .failure + .as_deref() + .is_some_and(|error| error.starts_with("plugin.build.cleanupFailed")) + { + return Err(process.failure.unwrap()); + } + let mut terminal = BuildRunTerminal { + kind: if !process.started { + "failedToStart" + } else if process.failure.is_some() || process.exit_code != Some(0) { + "failed" + } else { + "succeeded" + } + .into(), + exit_code: process.exit_code, + message: process.failure, + }; + let decoded = (|| -> Result<(), String> { + guard.checkpoint()?; + if !guard.cancelled() { + for (name, decoder, text) in [ + ("stdout", &mut stdout, &mut diagnostic_stdout), + ("stderr", &mut stderr, &mut diagnostic_stderr), + ] { + let tail = decoder.finish(); + text.push_str(&tail); + operation.publish(|| { + guard.checkpoint()?; + run.events.chunk(name, &tail) + })?; + } + } + guard.checkpoint()?; + // Keep independent streams separate so a partial stdout line cannot + // splice into a stderr diagnostic. Both streams remain in arrival order + // in chunk events; Core sees complete per-stream diagnostic lines. + let output = format!("{diagnostic_stdout}\n{diagnostic_stderr}"); + let decode = |command: &str, request: Value| -> Result { + guard.checkpoint()?; + let response = core.execute( + command, + json!({"manifest":operation.context.package.manifest, + "buildSystemID":operation.context.build_system_id, "request":request}), + )?; + core_ok(&response)?; + guard.checkpoint()?; + Ok(response) + }; + if run.diagnostics { + let response = decode( + "build.decodeDiagnostics", + json!({"workspaceRoot":operation.context.workspace_root, + "root":operation.context.root, "output":output}), + )?; + operation.publish(|| { + guard.checkpoint()?; + run.events.decoded( + "diagnostics", + "diagnostics", + response["diagnostics"].clone(), + ) + })?; + } + if run.kind == BuildRunKind::Test && !run.plan.results.is_null() && process.started { + let mut request = run.decode_request.clone(); + request["output"] = json!(output); + request["notBeforeMillis"] = json!(process + .started_at_millis + .ok_or("plugin.build.invalidPlan")?); + let response = decode("build.decodeTestResults", request)?; + if response["results"]["success"] == false { + terminal.kind = "failed".into(); + } + operation.publish(|| { + guard.checkpoint()?; + run.events + .decoded("testResults", "results", response["results"].clone()) + })?; + } + guard.checkpoint()?; + Ok(()) + })(); + if let Err(error) = decoded { + terminal.kind = if error_terminal(&error).kind == "cancelled" { + "cancelled" + } else if process.started { + "failed" + } else { + "failedToStart" + } + .into(); + terminal.message = Some(error); + } + Ok(terminal) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/run_tests.rs b/windows/tauri/src-tauri/src/plugin_host/build/run_tests.rs new file mode 100644 index 0000000000..29a75a69d4 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/run_tests.rs @@ -0,0 +1,971 @@ +//! Real Core task/test plans and output decoders with a controllable process +//! owner. No test starts a developer tool or waits for real time. + +use super::super::build_registry::*; +use super::super::build_resolver::WindowsBuildToolResolver; +use super::operation::*; +use super::output::*; +use super::process::ProcessStart; +use super::run::*; +use serde_json::{json, Value}; +use std::cell::{Cell, RefCell}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, UNIX_EPOCH}; + +const RUN_START: u64 = 1_700_000_000_000; +struct Core; +impl BuildCoreExecutor for Core { + fn execute(&self, command: &str, payload: Value) -> Result { + super::super::scan::core_execute(command, payload) + } +} +struct Fixture { + root: PathBuf, +} +impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!("lithe run tests {}", next_operation_id())); + std::fs::create_dir_all(&root).expect("fixture"); + let fixture = Self { root }; + fixture.file( + "Cargo.toml", + "[package]\nname=\"fixture\"\nversion=\"0.1.0\"\nedition=\"2021\"\n", + ); + fixture.file("src/main.rs", "fn main() {}\n"); + fixture.file("tools/cargo.exe", "placeholder"); + fixture + } + fn file(&self, relative: &str, text: &str) -> PathBuf { + let path = self.root.join(relative); + std::fs::create_dir_all(path.parent().expect("parent")).expect("directory"); + std::fs::write(&path, text).expect("file"); + path + } + fn path(&self) -> String { + self.root.to_string_lossy().into_owned() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} +fn manifest() -> Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json"); + serde_json::from_str(&std::fs::read_to_string(path).expect("fixture manifest")).expect("JSON") +} +struct Package(BuildPackageInput); +impl BuildPackageSource for Package { + fn accepted_packages(&self) -> Vec { + vec![self.0.clone()] + } + fn package(&self, id: &str) -> Option { + (id == self.0.plugin_id).then(|| self.0.clone()) + } +} +struct Options; +impl BuildOptionsPersistence for Options { + fn load(&self, _: &str) -> Result { + Ok(StoredBuildOptions::default()) + } + fn save(&self, _: &str, _: &StoredBuildOptions) -> Result<(), String> { + Ok(()) + } +} +fn registry( + fixture: &Fixture, + manifest: Value, + window: &str, +) -> (BuildRegistry, Package, BuildModelContext) { + let package = Package(BuildPackageInput { + plugin_id: manifest["id"].as_str().expect("ID").into(), + version: "0.1.0".into(), + manifest, + content_identity: "frozen-bytes".into(), + }); + let mut registry = BuildRegistry::new(); + let detected = registry + .refresh(&Core, &package, &Options, window, &fixture.path()) + .expect("passive detect"); + let context = registry + .model_context(window, &fixture.path(), &package.0.plugin_id, "cargo", ".") + .unwrap_or_else(|error| { + panic!( + "context: {error}; detection: {}", + serde_json::to_string(&detected).expect("detection diagnostic") + ) + }); + (registry, package, context) +} +#[derive(Default)] +struct Sink(Mutex>); +impl BuildOutputSink for Sink { + fn emit(&self, window: &str, event: Value) -> Result<(), String> { + self.0.lock().expect("events").push((window.into(), event)); + Ok(()) + } +} +fn selection(context: &BuildModelContext, kind: BuildRunKind) -> BuildRunSelection { + BuildRunSelection { + task_id: (kind == BuildRunKind::Task).then(|| "build".into()), + kind, + project_id: context.model["projects"][0]["id"] + .as_str() + .map(str::to_string), + target_id: None, + model_task_id: None, + suite: None, + case: None, + user_arguments: vec![], + } +} +fn operation( + fixture: &Fixture, + context: BuildModelContext, + selection: &BuildRunSelection, + sink: Arc, +) -> ModelOperation { + let id = next_operation_id(); + let run = prepare_run_plan( + &Core, + &context, + selection, + &fixture.root, + &fixture.root, + &id, + sink, + ) + .expect("Core task/test plan"); + let response=Core.execute("build.invocationPlan",json!({"manifest":context.package.manifest,"buildSystemID":"cargo","request":{"root":".","platform":"windows"}})).expect("invocation"); + core_ok(&response).expect("valid invocation"); + let resolver = WindowsBuildToolResolver::with_environment( + BTreeMap::from([( + "PATH".into(), + fixture.root.join("tools").to_string_lossy().into_owned(), + )]), + BTreeMap::new(), + ); + let plan = ModelCommandPlan { + arguments: run.plan.arguments.clone(), + environment: run.plan.environment.clone(), + working_directory: ".".into(), + output: "stdout".into(), + executes_project_code: true, + may_use_network: true, + }; + let cwd = WindowsBuildToolResolver::instance_directory(&response["invocation"], &fixture.root) + .expect("cwd"); + let mut operation = ModelOperation::new( + id, + context, + plan, + response["invocation"].clone(), + resolver, + PathBuf::new(), + cwd, + ); + operation.run = Some(run); + operation +} +struct Guard Result<(), String>>(F); +impl Result<(), String>> ModelExecutionGuard for Guard { + fn checkpoint(&self) -> Result<(), String> { + self.0() + } + fn cancelled(&self) -> bool { + self.0().is_err() + } +} +type OutputCallback<'a> = dyn FnMut(lithe_git_host::Stream, &[u8]) -> Result<(), String> + 'a; +struct Executor) -> BuildRunProcessOutput> { + action: F, + plans: RefCell>, +} +impl) -> BuildRunProcessOutput> + BuildProcessExecutor for Executor +{ + fn run( + &self, + plan: &BuildProcessPlan, + guard: &dyn ModelExecutionGuard, + ) -> Result { + guard.checkpoint()?; + self.plans.borrow_mut().push(plan.clone()); + assert_eq!(plan.arguments, ["--version"]); + Ok(BuildProcessOutput { + stdout: b"cargo 1.80.1".to_vec(), + stderr: vec![], + }) + } + fn run_command( + &self, + plan: &BuildProcessPlan, + guard: &dyn ModelExecutionGuard, + output: &mut OutputCallback<'_>, + ) -> BuildRunProcessOutput { + if let Err(error) = guard.checkpoint() { + return result(None, Some(&error), false); + } + self.plans.borrow_mut().push(plan.clone()); + (self.action)(plan, output) + } +} +fn executor) -> BuildRunProcessOutput>( + action: F, +) -> Executor { + Executor { + action, + plans: RefCell::new(vec![]), + } +} +fn result(code: Option, failure: Option<&str>, started: bool) -> BuildRunProcessOutput { + BuildRunProcessOutput { + exit_code: code, + started, + failure: failure.map(str::to_string), + started_at_millis: started.then_some(RUN_START), + } +} +fn finish(operation: &ModelOperation, terminal: &BuildRunTerminal) { + operation + .complete_run(terminal.clone(), || Ok(())) + .expect("terminal"); +} + +#[test] +fn successful_task_uses_core_argv_same_environment_and_ordered_window_events() { + let fixture = Fixture::new(); + let (_, _, context) = registry(&fixture, manifest(), "main"); + let sink = Arc::new(Sink::default()); + let operation = operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink.clone(), + ); + assert!( + sink.0.lock().expect("events").is_empty(), + "prepare never runs/probes" + ); + let executor = executor(|plan, output| { + assert_eq!(plan.arguments, ["build", "--message-format=short"]); + output(lithe_git_host::Stream::Stdout, b"compiling\n").expect("stdout"); + output( + lithe_git_host::Stream::Stderr, + b"src/main.rs:2:3: warning: fixture\n", + ) + .expect("stderr"); + result(Some(0), None, true) + }); + let terminal = execute_run(&operation, &Core, &executor, &Guard(|| Ok(()))).expect("run"); + finish(&operation, &terminal); + finish(&operation, &terminal); + assert_eq!(terminal.kind, "succeeded"); + let plans = executor.plans.borrow(); + assert_eq!(plans.len(), 2); + assert_eq!(plans[0].environment, plans[1].environment); + assert_eq!(plans[0].executable, plans[1].executable); + assert!(plans[1].executable.is_absolute()); + let events = sink.0.lock().expect("events"); + assert_eq!(events.len(), 4); + assert_eq!( + events + .iter() + .map(|(_, e)| e["type"].as_str().expect("type")) + .collect::>(), + ["chunk", "chunk", "diagnostics", "terminal"] + ); + for (window, event) in events.iter() { + assert_eq!(window, "main"); + assert_eq!(event["operationId"], operation.id); + assert_eq!(event["pluginId"], context.plugin_id); + assert_eq!(event["buildSystemId"], "cargo"); + assert_eq!(event["workspaceRoot"], fixture.path()); + assert_eq!(event["root"], "."); + assert_eq!(event["generation"], context.generation); + } + assert_eq!(events[2].1["diagnostics"][0]["path"], "src/main.rs"); + assert_eq!(events[2].1["diagnostics"][0]["line"], 2); + assert!(operation + .run + .as_ref() + .expect("run") + .events + .chunk("stdout", "late") + .is_err()); +} + +#[test] +fn unsuccessful_task_retains_output_and_core_diagnostics_before_terminal() { + let fixture = Fixture::new(); + let (_, _, context) = registry(&fixture, manifest(), "main"); + let sink = Arc::new(Sink::default()); + let operation = operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink.clone(), + ); + let executor = executor(|_, output| { + output( + lithe_git_host::Stream::Stderr, + b"src/main.rs:7:2: error: compile failed\n", + ) + .expect("output"); + result(Some(101), None, true) + }); + let terminal = + execute_run(&operation, &Core, &executor, &Guard(|| Ok(()))).expect("drained failure"); + finish(&operation, &terminal); + assert_eq!(terminal.kind, "failed"); + assert_eq!(terminal.exit_code, Some(101)); + let events = sink.0.lock().expect("events"); + assert_eq!(events[1].1["diagnostics"][0]["severity"], "error"); + assert_eq!(events.last().expect("terminal").1["type"], "terminal"); +} + +#[test] +fn test_reports_are_decoded_by_core_and_older_reports_are_excluded() { + let fixture = Fixture::new(); + let mut declaration = manifest(); + declaration["buildSystems"][0]["test"]["results"] = + json!({"decoder":"junitXml","reports":["reports/*.xml"]}); + let (_, _, context) = registry(&fixture, declaration, "main"); + let sink = Arc::new(Sink::default()); + let operation = operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Test), + sink.clone(), + ); + let executor = executor(|_, output| { + for (name, time, status) in [ + ("old", RUN_START - 1000, ""), + ("current", RUN_START + 1000, ""), + ] { + let path=fixture.file(&format!("reports/{name}.xml"),&format!("{status}")); + std::fs::OpenOptions::new() + .write(true) + .open(path) + .expect("report") + .set_modified(UNIX_EPOCH + Duration::from_millis(time)) + .expect("controlled report clock"); + } + output(lithe_git_host::Stream::Stdout, b"tests finished\n").expect("output"); + result(Some(0), None, true) + }); + let terminal = + execute_run(&operation, &Core, &executor, &Guard(|| Ok(()))).expect("decoded tests"); + finish(&operation, &terminal); + assert_eq!( + terminal.kind, "failed", + "test failures override a zero process exit" + ); + let events = sink.0.lock().expect("events"); + let results = events + .iter() + .find(|(_, event)| event["type"] == "testResults") + .expect("results"); + assert_eq!(results.1["results"]["summary"]["total"], 1); + assert_eq!(results.1["results"]["tests"][0]["case"], "current"); +} + +#[test] +fn a_report_written_before_the_started_hook_belongs_to_the_current_run() { + let fixture = Fixture::new(); + let mut declaration = manifest(); + declaration["buildSystems"][0]["test"]["results"] = + json!({"decoder":"junitXml","reports":["reports/*.xml"]}); + let (_, _, context) = registry(&fixture, declaration, "main"); + let sink = Arc::new(Sink::default()); + let operation = operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Test), + sink.clone(), + ); + let executor = executor(|_, _| { + let clock = Cell::new(RUN_START); + let start = ProcessStart::capture(|| Some(clock.get())); + assert!(!start.started()); + assert_eq!(start.started_at_millis(), None, "no spawn, no report time"); + // The child writes before its native owner gets the started callback. + // Advance a controlled clock instead of relying on scheduler timing. + let report = fixture.file( + "reports/fast.xml", + "", + ); + std::fs::OpenOptions::new() + .write(true) + .open(report) + .expect("report") + .set_modified(UNIX_EPOCH + Duration::from_millis(clock.get())) + .expect("controlled report clock"); + clock.set(RUN_START + 1000); + start.mark_started(); + assert_eq!(start.started_at_millis(), Some(RUN_START)); + BuildRunProcessOutput { + exit_code: Some(0), + started: start.started(), + failure: None, + started_at_millis: start.started_at_millis(), + } + }); + let terminal = execute_run(&operation, &Core, &executor, &Guard(|| Ok(()))) + .expect("decode the immediate report"); + assert_eq!( + terminal.kind, "failed", + "the early failure cannot disappear" + ); + let events = sink.0.lock().expect("events"); + let results = events + .iter() + .find(|(_, event)| event["type"] == "testResults") + .expect("results"); + assert_eq!(results.1["results"]["summary"]["total"], 1); + assert_eq!(results.1["results"]["summary"]["failed"], 1); +} + +#[test] +fn workspace_reports_and_model_selections_require_a_current_declared_model() { + let fixture = Fixture::new(); + let mut declaration = manifest(); + declaration["buildSystems"][0]["test"]["scopes"] = json!(["workspace", "project", "target"]); + declaration["buildSystems"][0]["tasks"][0]["scopes"] = json!(["workspace", "project"]); + declaration["buildSystems"][0]["test"]["results"] = + json!({"decoder":"junitXml","reports":["reports/*.xml"]}); + let (_, _, static_context) = registry(&fixture, declaration.clone(), "main"); + let mut selected = selection(&static_context, BuildRunKind::Test); + selected.project_id = None; + let prepare = |context: &BuildModelContext, selected: &BuildRunSelection| { + prepare_run_plan( + &Core, + context, + selected, + &fixture.root, + &fixture.root, + "model-validity", + Arc::new(Sink::default()), + ) + }; + assert!( + prepare(&static_context, &selected).is_ok(), + "fresh static model" + ); + + declaration["buildSystems"][0]["model"] = json!({ + "source":"command", "arguments":["metadata"], "decoder":"litheBuildModel", + "executesProjectCode":false, "mayUseNetwork":false, + }); + let (mut registry, package, missing) = registry(&fixture, declaration, "main"); + assert!( + missing.model.is_null(), + "detection does not execute the model" + ); + assert_eq!( + prepare(&missing, &selected).err().as_deref(), + Some("plugin.build.modelReloadRequired") + ); + let before = registry + .model_fingerprint(&Core, &missing) + .expect("fingerprint"); + registry + .apply_command_model(&Core, &missing, static_context.model.clone(), &before) + .expect("load a model already decoded by Core"); + let loaded = registry + .model_context("main", &fixture.path(), &missing.plugin_id, "cargo", ".") + .expect("loaded context"); + assert!(prepare(&loaded, &selected).is_ok(), "loaded command model"); + fixture.file( + "Cargo.toml", + "[package]\nname=\"changed\"\nversion=\"0.1.0\"\n", + ); + registry + .sync_check( + &Core, + &package, + "main", + &fixture.path(), + &missing.plugin_id, + "cargo", + ".", + ) + .expect("mark model stale"); + assert!( + !registry.context_is_current(&loaded), + "old approval is invalid" + ); + let stale = registry + .model_context("main", &fixture.path(), &missing.plugin_id, "cargo", ".") + .expect("stale context remains reloadable"); + assert!(stale.model_stale); + assert_eq!( + prepare(&stale, &selected).err().as_deref(), + Some("plugin.build.modelReloadRequired") + ); + for kind in [BuildRunKind::Task, BuildRunKind::Test] { + let selected = selection(&stale, kind); + assert_eq!( + prepare(&stale, &selected).err().as_deref(), + Some("plugin.build.modelReloadRequired"), + "selected model entities must be current" + ); + } + let mut independent_task = selection(&stale, BuildRunKind::Task); + independent_task.project_id = None; + assert!( + prepare(&stale, &independent_task).is_ok(), + "a workspace task with no model selection does not require a model" + ); +} + +#[test] +fn workspace_reports_without_a_model_declaration_remain_available() { + let fixture = Fixture::new(); + let mut declaration = manifest(); + declaration["buildSystems"][0] + .as_object_mut() + .expect("system") + .remove("model"); + declaration["buildSystems"][0]["options"] + .as_array_mut() + .expect("options") + .retain(|option| option["id"] != "features"); + declaration["buildSystems"][0]["run"] = json!([]); + declaration["buildSystems"][0]["test"]["scopes"] = json!(["workspace"]); + declaration["buildSystems"][0]["test"]["targetTags"] = json!([]); + declaration["buildSystems"][0]["test"]["filters"] = json!([]); + declaration["buildSystems"][0]["test"]["arguments"] = json!(["test", "${optionArguments}"]); + declaration["buildSystems"][0]["test"]["results"] = + json!({"decoder":"junitXml","reports":["reports/*.xml"]}); + let (_, _, context) = registry(&fixture, declaration, "main"); + assert!(context.model.is_null()); + let selected = selection(&context, BuildRunKind::Test); + assert!(selected.project_id.is_none()); + assert!(prepare_run_plan( + &Core, + &context, + &selected, + &fixture.root, + &fixture.root, + "no-model-needed", + Arc::new(Sink::default()), + ) + .is_ok()); +} + +#[test] +fn pending_cancel_and_duplicate_confirmation_never_release_an_active_slot_early() { + let fixture = Fixture::new(); + let (_, _, context) = registry(&fixture, manifest(), "main"); + let sink = Arc::new(Sink::default()); + let operations = BuildOperations::default(); + let first = operations + .insert(operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink.clone(), + )) + .expect("prepare"); + let pending = operations + .get("main", &first.operation_id) + .expect("pending"); + pending.cancel(); + pending.cancel(); + assert!(operations.claim("main", &first.operation_id).is_err()); + assert_eq!(sink.0.lock().expect("events").len(), 1); + let second = operations + .insert(operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink.clone(), + )) + .expect("new"); + let running = operations + .claim("main", &second.operation_id) + .expect("claim"); + assert!(operations.claim("main", &second.operation_id).is_err()); + running.0.cancel(); + assert!(!running.0.finished()); + assert!(operations + .insert(operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink.clone() + )) + .is_err()); + drop(running); + assert!(operations + .insert(operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink + )) + .is_ok()); +} + +#[test] +fn cancellation_and_terminal_publication_respect_the_winning_order() { + for cancel_first in [true, false] { + let fixture = Fixture::new(); + let (_, _, context) = registry(&fixture, manifest(), "main"); + let sink = Arc::new(Sink::default()); + let operations = BuildOperations::default(); + let review = operations + .insert(operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink.clone(), + )) + .expect("prepare"); + let running = operations + .claim("main", &review.operation_id) + .expect("claim"); + // Execution has drained but its terminal has not been published. The + // controlled order reproduces both sides of the cancel/finish race. + let executor = executor(|_, _| result(Some(0), None, true)); + let terminal = + execute_run(&running.0, &Core, &executor, &Guard(|| Ok(()))).expect("drained run"); + assert_eq!(terminal.kind, "succeeded"); + if cancel_first { + running.0.cancel(); + } + let terminal = running + .0 + .complete_run(terminal, || Ok(())) + .expect("publish"); + running.0.cancel(); + assert!( + running.0.publish(|| Ok(())).is_err(), + "no publication after cancel" + ); + assert!(!running.0.finished(), "owner still holds the busy slot"); + let owner = running.0.clone(); + drop(running); + assert!(owner.finished()); + let events = sink.0.lock().expect("events"); + let terminals: Vec<_> = events + .iter() + .filter(|(_, event)| event["type"] == "terminal") + .collect(); + assert_eq!(terminals.len(), 1); + assert_eq!( + terminal.kind, + if cancel_first { + "cancelled" + } else { + "succeeded" + } + ); + assert_eq!(terminals[0].1["terminal"]["kind"], terminal.kind); + } +} + +#[test] +fn two_windows_have_independent_slots_and_cannot_confirm_each_others_run() { + let fixture = Fixture::new(); + let (mut registry, package, first) = registry(&fixture, manifest(), "main"); + registry + .refresh( + &Core, + &package, + &Options, + "workspace-second", + &fixture.path(), + ) + .expect("second window"); + let second = registry + .model_context( + "workspace-second", + &fixture.path(), + &first.plugin_id, + "cargo", + ".", + ) + .expect("second context"); + let sink = Arc::new(Sink::default()); + let operations = BuildOperations::default(); + let a = operations + .insert(operation( + &fixture, + first.clone(), + &selection(&first, BuildRunKind::Task), + sink.clone(), + )) + .expect("first"); + let b = operations + .insert(operation( + &fixture, + second.clone(), + &selection(&second, BuildRunKind::Task), + sink.clone(), + )) + .expect("second"); + assert!(operations.claim("main", &b.operation_id).is_err()); + let running = operations + .claim("main", &a.operation_id) + .expect("main claim"); + operations.cancel_matching(|operation| operation.context.window_label == "workspace-second"); + assert!(!running.0.cancelled()); + let events = sink.0.lock().expect("events"); + assert_eq!(events.len(), 1); + assert_eq!(events[0].0, "workspace-second"); + assert_eq!(events[0].1["operationId"], b.operation_id); +} + +#[test] +fn suspension_workspace_return_and_plugin_revocation_invalidate_approval() { + let fixture = Fixture::new(); + let (mut registry, package, context) = registry(&fixture, manifest(), "main"); + registry.suspend_execution("main"); + registry.suspend_execution("main"); + assert!(!registry.context_is_current(&context)); + assert!(registry + .model_context("main", &fixture.path(), &context.plugin_id, "cargo", ".") + .is_err()); + registry.resume_execution("main"); + assert!( + registry + .model_context("main", &fixture.path(), &context.plugin_id, "cargo", ".") + .is_err(), + "overlapping drains remain suspended" + ); + registry.resume_execution("main"); + assert!( + !registry.context_is_current(&context), + "resuming cannot resurrect approval" + ); + let current = registry + .model_context("main", &fixture.path(), &context.plugin_id, "cargo", ".") + .expect("new"); + registry.close_workspace("main", &fixture.path()); + registry + .refresh(&Core, &package, &Options, "main", &fixture.path()) + .expect("return"); + assert!(!registry.context_is_current(¤t)); + let current = registry + .model_context("main", &fixture.path(), &context.plugin_id, "cargo", ".") + .expect("current"); + registry.drop_plugin(&context.plugin_id); + assert!(!registry.context_is_current(¤t)); +} + +#[test] +fn model_replacement_and_window_recreation_never_reuse_an_approved_snapshot() { + let fixture = Fixture::new(); + let (mut registry, package, context) = registry(&fixture, manifest(), "main"); + let sink = Arc::new(Sink::default()); + let operation = operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink.clone(), + ); + let executor = executor(|_, _| result(Some(0), None, true)); + let terminal = execute_run(&operation, &Core, &executor, &Guard(|| Ok(()))).expect("drained"); + let before = registry + .model_fingerprint(&Core, &context) + .expect("fingerprint"); + registry + .apply_command_model(&Core, &context, context.model.clone(), &before) + .expect("replace model even with identical content"); + assert!(!registry.context_is_current(&context)); + let terminal = operation + .complete_run(terminal, || { + if registry.context_is_current(&context) { + Ok(()) + } else { + Err("plugin.build.staleContext".into()) + } + }) + .expect("last checkpoint rejects the replaced model"); + assert_eq!(terminal.kind, "cancelled"); + assert_eq!( + sink.0.lock().expect("events").last().expect("terminal").1["terminal"]["kind"], + "cancelled" + ); + let current = registry + .model_context("main", &fixture.path(), &context.plugin_id, "cargo", ".") + .expect("new model context"); + registry.close_window("main"); + registry + .refresh(&Core, &package, &Options, "main", &fixture.path()) + .expect("recreated window"); + assert!(!registry.context_is_current(¤t)); +} + +#[test] +fn timeout_cancel_output_limit_start_failure_and_cleanup_failure_have_distinct_outcomes() { + for (code, started, expected) in [ + ("plugin.build.timeout", true, "failed"), + ("plugin.build.cancelled", true, "cancelled"), + ("plugin.build.outputLimit", true, "failed"), + ("plugin.build.processFailed", false, "failedToStart"), + ("plugin.build.cleanupFailed", true, "occupied"), + ] { + let fixture = Fixture::new(); + let (_, _, context) = registry(&fixture, manifest(), "main"); + let sink = Arc::new(Sink::default()); + let operations = BuildOperations::default(); + let review = operations + .insert(operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink.clone(), + )) + .expect("prepare"); + let running = operations + .claim("main", &review.operation_id) + .expect("claim"); + let cancelled = Cell::new(false); + let executor = executor(|_, _| { + cancelled.set(code == "plugin.build.cancelled"); + result(None, Some(code), started) + }); + let guard = Guard(|| { + if cancelled.get() { + Err("plugin.build.cancelled".into()) + } else { + Ok(()) + } + }); + let terminal = execute_run(&running.0, &Core, &executor, &guard); + if expected == "occupied" { + assert_eq!(terminal.err().as_deref(), Some(code)); + running.0.retain_failed_cleanup(()); + let control = running.0.clone(); + drop(running); + assert!(!control.finished()); + assert!(sink.0.lock().expect("events").is_empty()); + } else { + let terminal = terminal.expect("drained"); + assert_eq!(terminal.kind, expected); + finish(&running.0, &terminal); + drop(running); + assert_eq!( + sink.0 + .lock() + .expect("events") + .iter() + .filter(|(_, event)| event["type"] == "terminal") + .count(), + 1 + ); + } + } +} + +#[test] +fn revoke_during_streaming_drops_late_output_and_decoding() { + let fixture = Fixture::new(); + let (_, _, context) = registry(&fixture, manifest(), "main"); + let sink = Arc::new(Sink::default()); + let operation = operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink.clone(), + ); + let revoked = Cell::new(false); + let executor = executor(|_, output| { + output(lithe_git_host::Stream::Stdout, b"early\n").expect("early"); + revoked.set(true); + assert!(output(lithe_git_host::Stream::Stderr, b"late\n").is_err()); + result(None, Some("plugin.build.cancelled"), true) + }); + let terminal = execute_run( + &operation, + &Core, + &executor, + &Guard(|| { + if revoked.get() { + Err("plugin.build.packageChanged".into()) + } else { + Ok(()) + } + }), + ) + .expect("cancel"); + finish(&operation, &terminal); + let events = sink.0.lock().expect("events"); + assert_eq!(events.len(), 2); + assert_eq!(events[0].1["text"], "early\n"); + assert_eq!(terminal.kind, "cancelled"); +} + +#[test] +fn selection_refuses_injected_commands_entity_paths_reports_and_unknown_properties() { + for field in [ + "executable", + "arguments", + "environment", + "workingDirectory", + "reports", + "permissions", + "url", + "project", + ] { + let mut input = json!({"kind":"task","taskId":"build"}); + input[field] = json!("untrusted"); + assert!( + serde_json::from_value::(input).is_err(), + "{field}" + ); + } + let fixture = Fixture::new(); + let (_, _, context) = registry(&fixture, manifest(), "main"); + let mut selected = selection(&context, BuildRunKind::Task); + selected.project_id = Some("../outside".into()); + assert!(prepare_run_plan( + &Core, + &context, + &selected, + &fixture.root, + &fixture.root, + "invalid", + Arc::new(Sink::default()) + ) + .is_err()); +} + +#[test] +fn output_budget_and_split_utf8_are_bounded_without_corrupting_characters() { + let mut stream = Utf8Stream::default(); + let bytes = "中文🙂".as_bytes(); + let mut text = String::new(); + for byte in bytes { + text.push_str(&stream.push(&[*byte])); + } + text.push_str(&stream.finish()); + assert_eq!(text, "中文🙂"); + let fixture = Fixture::new(); + let (_, _, context) = registry(&fixture, manifest(), "main"); + let sink = Arc::new(Sink::default()); + let events = RunEvents::new(&context, "bounded", sink.clone()); + let chunk = "x".repeat(8192); + for _ in 0..MAX_RUN_OUTPUT / 8192 { + events.chunk("stdout", &chunk).expect("within budget"); + } + assert!(events.chunk("stdout", "overflow").is_err()); + let terminal = BuildRunTerminal { + kind: "failed".into(), + exit_code: None, + message: Some("plugin.build.outputLimit".into()), + }; + events + .terminal(&terminal) + .expect("terminal survives output budget"); + assert_eq!( + sink.0.lock().expect("events").last().expect("last").1["type"], + "terminal" + ); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/tests.rs b/windows/tauri/src-tauri/src/plugin_host/build/tests.rs new file mode 100644 index 0000000000..6caf2b587a --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/tests.rs @@ -0,0 +1,646 @@ +//! Real Core plans and decoders with a controlled executor. No test launches +//! developer tools, waits on a wall clock or depends on installed runtimes. + +use super::super::build_registry::*; +use super::super::build_resolver::{RuntimeExecutableInput, WindowsBuildToolResolver}; +use super::operation::*; +use super::process::{process_failure, ModelOutputFile, NativeBuildExecutor}; +use serde_json::{json, Value}; +use std::cell::{Cell, RefCell}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +struct Core; +impl BuildCoreExecutor for Core { + fn execute(&self, command: &str, payload: Value) -> Result { + super::super::scan::core_execute(command, payload) + } +} + +struct Fixture { + path: PathBuf, +} +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("lithe model tests {}", next_operation_id())); + std::fs::create_dir_all(&path).expect("fixture"); + Self { path } + } + fn file(&self, relative: &str, contents: &str) -> PathBuf { + let path = self.path.join(relative); + std::fs::create_dir_all(path.parent().expect("parent")).expect("parent directory"); + std::fs::write(&path, contents).expect("fixture file"); + path + } + fn workspace(&self) -> String { + self.path.to_string_lossy().into_owned() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +fn manifest(name: &str) -> Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../shared/contracts/plugins/v1/fixtures") + .join(name); + serde_json::from_str(&std::fs::read_to_string(path).expect("fixture JSON")).expect("manifest") +} + +struct Packages(Mutex); +impl BuildPackageSource for Packages { + fn accepted_packages(&self) -> Vec { + vec![self.0.lock().expect("package").clone()] + } + fn package(&self, id: &str) -> Option { + let package = self.0.lock().expect("package"); + (package.plugin_id == id).then(|| package.clone()) + } +} +struct Options; +impl BuildOptionsPersistence for Options { + fn load(&self, _: &str) -> Result { + Ok(StoredBuildOptions::default()) + } + fn save(&self, _: &str, _: &StoredBuildOptions) -> Result<(), String> { + Ok(()) + } +} + +fn context( + fixture: &Fixture, + declaration: &Value, + system: &str, +) -> (BuildRegistry, Packages, BuildModelContext) { + let packages = Packages(Mutex::new(BuildPackageInput { + plugin_id: declaration["id"].as_str().expect("plugin ID").into(), + version: "0.1.0".into(), + manifest: declaration.clone(), + content_identity: "approved-fixture-bytes".into(), + })); + let mut registry = BuildRegistry::new(); + registry + .refresh(&Core, &packages, &Options, "main", &fixture.workspace()) + .expect("passive detection"); + let id = declaration["id"].as_str().expect("plugin ID"); + let context = registry + .model_context("main", &fixture.workspace(), id, system, ".") + .expect("model context"); + (registry, packages, context) +} + +fn operation(fixture: &Fixture, context: BuildModelContext, settings: Value) -> ModelOperation { + let id = next_operation_id(); + let output = fixture.path.join(format!("{id}.json")); + let response = Core.execute("build.modelPlan", json!({"manifest": context.package.manifest, + "buildSystemID": context.build_system_id, "request": {"root": context.root, "options": context.options, + "pluginDirectory": fixture.path, "outputFile": output}})).expect("model plan"); + core_ok(&response).expect("valid model plan"); + let plan = serde_json::from_value(response["plan"].clone()).expect("command plan"); + let invocation = Core.execute("build.invocationPlan", json!({"manifest": context.package.manifest, + "buildSystemID": context.build_system_id, "request": {"root": context.root, "platform": "windows", "settings": settings}})).expect("invocation"); + core_ok(&invocation).expect("valid invocation"); + let runtimes = BTreeMap::from([ + ( + "go".into(), + RuntimeExecutableInput::discovered(fixture.file("tools/go.exe", "placeholder")), + ), + ( + "java".into(), + RuntimeExecutableInput::discovered(fixture.file("tools/java.exe", "placeholder")), + ), + ]); + let resolver = WindowsBuildToolResolver::with_environment(BTreeMap::new(), runtimes); + let directory = + WindowsBuildToolResolver::instance_directory(&invocation["invocation"], &fixture.path) + .expect("cwd"); + ModelOperation::new( + id, + context, + plan, + invocation["invocation"].clone(), + resolver, + output, + directory, + ) +} + +struct Guard Result<(), String>>(F); +impl Result<(), String>> ModelExecutionGuard for Guard { + fn checkpoint(&self) -> Result<(), String> { + self.0() + } + fn cancelled(&self) -> bool { + self.0().is_err() + } +} +struct Executor Result> { + action: F, + plans: RefCell>, +} +impl Result> Executor { + fn new(action: F) -> Self { + Self { + action, + plans: RefCell::new(Vec::new()), + } + } +} +impl Result> BuildProcessExecutor + for Executor +{ + fn run( + &self, + plan: &BuildProcessPlan, + guard: &dyn ModelExecutionGuard, + ) -> Result { + guard.checkpoint()?; + self.plans.borrow_mut().push(plan.clone()); + (self.action)(plan) + } +} +fn output(stdout: impl Into, stderr: &str) -> BuildProcessOutput { + BuildProcessOutput { + stdout: stdout.into().into_bytes(), + stderr: stderr.as_bytes().to_vec(), + } +} +fn go_output(fixture: &Fixture) -> String { + json!({"Module":{"Path":"example.test/app", "Dir":fixture.workspace(), "GoVersion":"1.22"}, + "Name":"main", "ImportPath":"example.test/app", "Dir":fixture.workspace()}) + .to_string() +} + +#[test] +fn command_stdout_uses_real_core_and_frozen_runtime_environment() { + let fixture = Fixture::new(); + fixture.file("go.mod", "module example.test/app"); + let declaration = manifest("build-command-stdout.plugin.json"); + let (mut registry, _, context) = context(&fixture, &declaration, "go"); + let before = registry + .model_fingerprint(&Core, &context) + .expect("fingerprint"); + let operation = operation(&fixture, context, json!({})); + let executor = Executor::new(|plan: &BuildProcessPlan| { + Ok(if plan.arguments == ["version"] { + output("go version go1.22.1 windows/amd64", "") + } else { + output(go_output(&fixture), "model warning") + }) + }); + let loaded = execute_model(&operation, &Core, &executor, &Guard(|| Ok(()))).expect("model"); + assert_eq!(loaded.stderr, "model warning"); + assert_eq!(loaded.model["projects"][0]["name"], "example.test/app"); + let plans = executor.plans.borrow(); + assert_eq!(plans.len(), 2); + assert_eq!(plans[0].executable, plans[1].executable); + assert_eq!(plans[0].environment, plans[1].environment); + assert_eq!(plans[1].arguments, ["list", "-json", "./..."]); + assert_eq!(plans[0].timeout, PROBE_TIMEOUT); + assert_eq!(plans[1].output_limit, MODEL_OUTPUT_LIMIT); + let view = registry + .apply_command_model(&Core, &operation.context, loaded.model, &before) + .expect("publish"); + assert_eq!( + view.instances[0]["model"]["projects"][0]["name"], + "example.test/app" + ); +} + +#[test] +fn command_output_file_uses_core_argv_and_cleans_its_owned_resource() { + let fixture = Fixture::new(); + fixture.file("build.gradle", ""); + fixture.file("gradlew.bat", "placeholder"); + fixture.file("gradle/wrapper/gradle-wrapper.properties", "placeholder"); + let declaration = manifest("build-command-output-file.plugin.json"); + let (_, _, context) = context(&fixture, &declaration, "gradle"); + let operation = operation(&fixture, context, json!({})); + assert!(operation.review().executes_project_code); + let resource = ModelOutputFile::create(&operation.output_file).expect("fresh output"); + let executor = Executor::new(|plan: &BuildProcessPlan| { + if plan.arguments == ["-version"] { + return Ok(output("", "java version \"17.0.9\"")); + } + let path = plan.output_file.as_ref().expect("Core output file channel"); + assert!(plan + .arguments + .contains(&format!("-PlitheModelOutput={}", path.display()))); + std::fs::write( + path, + json!({"format":"litheBuildModel","version":1,"projects":[{"path":".","name":"fixture"}]}).to_string(), + ) + .expect("controlled output"); + Ok(output( + "stdout is intentionally not model JSON", + "gradle diagnostic", + )) + }); + let loaded = + execute_model(&operation, &Core, &executor, &Guard(|| Ok(()))).expect("file model"); + assert_eq!(loaded.model["projects"][0]["name"], "fixture"); + assert_eq!( + executor.plans.borrow().len(), + 2, + "wrapper must never receive a version probe" + ); + drop(resource); + assert!(!operation.output_file.exists()); +} + +#[test] +fn configured_java_freezes_the_verified_jdk_home_for_probe_and_wrapper() { + let fixture = Fixture::new(); + fixture.file("build.gradle", ""); + fixture.file("gradlew.bat", "placeholder"); + fixture.file("gradle/wrapper/gradle-wrapper.properties", "placeholder"); + let selected = fixture.file("selected jdk/bin/java.exe", "placeholder"); + let old = fixture.file("inherited jdk/bin/java.exe", "placeholder"); + let runtime = super::host::explicit_runtime_input("java", selected.clone()) + .expect("passive JDK validation"); + let selected_home = runtime.environment["JAVA_HOME"].clone(); + assert_eq!( + crate::run::java_executable(Path::new(&selected_home)) + .expect("validated Java") + .canonicalize() + .expect("canonical selected Java"), + selected.canonicalize().expect("canonical fixture Java") + ); + assert!(super::host::explicit_runtime_input( + "java", + fixture.file("launchers/java.exe", "not a JDK layout") + ) + .is_err()); + assert!(super::host::explicit_runtime_input( + "java", + fixture.file("selected jdk/bin/unrelated.exe", "another launcher") + ) + .is_err()); + let declaration = manifest("build-command-output-file.plugin.json"); + let (_, _, context) = context(&fixture, &declaration, "gradle"); + let mut operation = operation(&fixture, context, json!({})); + operation.resolver = WindowsBuildToolResolver::with_environment( + BTreeMap::from([( + "JAVA_HOME".into(), + old.parent() + .and_then(Path::parent) + .expect("old JDK home") + .to_string_lossy() + .into_owned(), + )]), + BTreeMap::from([("java".into(), runtime)]), + ); + let _output = ModelOutputFile::create(&operation.output_file).expect("owned model output"); + let executor = Executor::new(|plan: &BuildProcessPlan| { + assert_eq!(plan.environment["JAVA_HOME"], selected_home); + if plan.arguments == ["-version"] { + assert_eq!(plan.executable, selected); + Ok(output("", "java version \"17.0.9\"")) + } else { + std::fs::write( + plan.output_file.as_ref().expect("model output"), + json!({"format":"litheBuildModel","version":1,"projects":[{"path":".","name":"fixture"}]}).to_string(), + ) + .expect("controlled model"); + Ok(output("", "model warning")) + } + }); + let loaded = execute_model(&operation, &Core, &executor, &Guard(|| Ok(()))) + .expect("model uses the selected JDK"); + assert_eq!(loaded.model["projects"][0]["name"], "fixture"); + assert_eq!(executor.plans.borrow().len(), 2); +} + +#[test] +fn process_failure_preserves_bounded_diagnostics_after_shared_redaction() { + let error = process_failure( + "unsuccessful exit", + b"model output before failure", + b"cannot resolve dependency; password=\"fixture secret\"; Authorization: Bearer fixture-token", + ); + assert!(error.starts_with("plugin.build.processFailed:")); + assert!(error.contains("cannot resolve dependency")); + assert!(error.contains("stdout: model output before failure")); + assert!(error.contains("stderr:")); + assert!(error.contains("")); + assert!(!error.contains("fixture secret")); + assert!(!error.contains("fixture-token")); + + // Long multibyte text and a quoted secret crossing the display boundary + // cannot exceed the retained error budget or expose a partial secret. + let stderr = format!("{} password=\"{}\"", "界".repeat(1300), "s".repeat(20_000)); + let long = process_failure("unsuccessful exit", &[], stderr.as_bytes()); + assert!(long.contains("[truncated]")); + assert!(long.len() < 4300); + assert!(!long.contains("ssss")); +} + +#[test] +fn consent_context_switch_and_same_version_package_replacement_refuse_execution() { + let fixture = Fixture::new(); + fixture.file("go.mod", "module example.test/app"); + let declaration = manifest("build-command-stdout.plugin.json"); + let (mut registry, packages, context) = context(&fixture, &declaration, "go"); + let operation = operation(&fixture, context, json!({})); + registry.close_workspace("main", &fixture.workspace()); + let executor = Executor::new(|_: &BuildProcessPlan| panic!("stale consent must never execute")); + let guard = Guard(|| { + if registry.context_is_current(&operation.context) { + Ok(()) + } else { + Err("plugin.build.staleContext".into()) + } + }); + assert!(execute_model(&operation, &Core, &executor, &guard).is_err()); + assert!(executor.plans.borrow().is_empty()); + packages.0.lock().expect("package").content_identity = "replaced-same-version".into(); + let guard = Guard(|| { + if packages.package(&operation.context.plugin_id).as_ref() + == Some(&operation.context.package) + { + Ok(()) + } else { + Err("plugin.build.packageChanged".into()) + } + }); + assert!(execute_model(&operation, &Core, &executor, &guard).is_err()); +} + +#[test] +fn cancellation_and_revocation_after_runtime_probe_never_start_the_model() { + for code in [ + "plugin.build.cancelled", + "plugin.build.staleContext", + "plugin.build.packageChanged", + ] { + let fixture = Fixture::new(); + fixture.file("go.mod", "module example.test/app"); + let declaration = manifest("build-command-stdout.plugin.json"); + let (_, _, context) = context(&fixture, &declaration, "go"); + let operation = operation(&fixture, context, json!({})); + let invalid = Cell::new(false); + let guard = Guard(|| { + if invalid.get() { + Err(code.into()) + } else { + Ok(()) + } + }); + let executor = Executor::new(|_: &BuildProcessPlan| { + invalid.set(true); + Ok(output("go version go1.22.1 windows/amd64", "")) + }); + assert!(execute_model(&operation, &Core, &executor, &guard).is_err()); + assert_eq!(executor.plans.borrow().len(), 1); + } +} + +#[test] +fn timeout_output_limit_and_cleanup_failures_cannot_publish_a_model() { + for code in [ + "plugin.build.timeout", + "plugin.build.outputLimit", + "plugin.build.cleanupFailed", + ] { + let fixture = Fixture::new(); + fixture.file("go.mod", "module example.test/app"); + let declaration = manifest("build-command-stdout.plugin.json"); + let (_, _, context) = context(&fixture, &declaration, "go"); + let operation = operation(&fixture, context, json!({})); + let executor = Executor::new(|plan: &BuildProcessPlan| { + if plan.arguments == ["version"] { + Ok(output("go version go1.22.1 windows/amd64", "")) + } else { + Err(code.into()) + } + }); + let error = execute_model(&operation, &Core, &executor, &Guard(|| Ok(()))) + .err() + .expect("failure"); + assert_eq!(error, code); + assert!(!operation.output_file.exists()); + } +} + +#[test] +fn operation_slot_stays_busy_until_execution_owner_drains_and_is_window_bound() { + let fixture = Fixture::new(); + fixture.file("go.mod", "module example.test/app"); + let declaration = manifest("build-command-stdout.plugin.json"); + let (_, _, context) = context(&fixture, &declaration, "go"); + let operations = BuildOperations::default(); + let review = operations + .insert(operation(&fixture, context.clone(), json!({}))) + .expect("prepare"); + assert!(operations + .claim("other-window", &review.operation_id) + .is_err()); + let running = operations + .claim("main", &review.operation_id) + .expect("claim"); + assert!(operations.claim("main", &review.operation_id).is_err()); + running.0.cancel(); + assert!( + !running.0.finished(), + "cancel is not process-tree completion" + ); + assert!(operations + .insert(operation(&fixture, context.clone(), json!({}))) + .is_err()); + assert!(running.0.publish(|| Ok(())).is_err()); + let control = running.0.clone(); + drop(running); + assert!(control.finished()); + assert!(operations + .insert(operation(&fixture, context, json!({}))) + .is_ok()); +} + +#[tokio::test] +async fn scoped_drain_retries_exact_owners_and_drains_siblings_after_one_cleanup_failure() { + let fixture = Fixture::new(); + fixture.file("go.mod", "module example.test/app"); + let declaration = manifest("build-command-stdout.plugin.json"); + let (_, _, context) = context(&fixture, &declaration, "go"); + let first = std::sync::Arc::new(operation(&fixture, context.clone(), json!({}))); + let second = std::sync::Arc::new(operation(&fixture, context, json!({}))); + let owners = vec![first.clone(), second.clone()]; + let seen = RefCell::new(Vec::new()); + let fail = Cell::new(true); + let cleanup = |owner: std::sync::Arc| { + assert!(std::sync::Arc::ptr_eq(&owner, &first) || std::sync::Arc::ptr_eq(&owner, &second)); + seen.borrow_mut().push(owner.id.clone()); + owner.cancel(); + std::future::ready(if fail.get() && std::sync::Arc::ptr_eq(&owner, &first) { + Err("plugin.build.cleanupFailed".into()) + } else { + Ok(()) + }) + }; + let first_drain = tokio::time::timeout( + std::time::Duration::from_secs(2), + super::host::drain_with(owners.clone(), &cleanup), + ) + .await + .expect("controlled cleanup must finish"); + assert_eq!(first_drain.unwrap_err(), "plugin.build.cleanupFailed"); + assert_eq!(*seen.borrow(), vec![first.id.clone(), second.id.clone()]); + fail.set(false); + tokio::time::timeout( + std::time::Duration::from_secs(2), + super::host::drain_with(owners, &cleanup), + ) + .await + .expect("controlled retry must finish") + .unwrap(); + assert_eq!( + *seen.borrow(), + vec![ + first.id.clone(), + second.id.clone(), + first.id.clone(), + second.id.clone() + ] + ); +} + +#[test] +fn explicit_path_with_spaces_is_frozen_and_invalid_path_never_falls_back() { + let fixture = Fixture::new(); + fixture.file("go.mod", "module example.test/app"); + let mut declaration = manifest("build-command-stdout.plugin.json"); + declaration["requires"]["settings"] = json!(2); + declaration["requires"]["executableResolution"] = json!(1); + declaration["settings"] = manifest("build-tool-path.plugin.json")["settings"].clone(); + declaration["settings"][0]["page"]["buildSystemID"] = json!("go"); + declaration["buildSystems"][0]["invocation"]["executables"] = json!([ + {"kind":"setting", "settingID":"toolPath"}, {"kind":"runtime", "runtime":"go"} + ]); + let (_, _, context) = context(&fixture, &declaration, "go"); + let tool = fixture.file("custom tools/build.exe", "placeholder"); + for configured in [tool.clone(), fixture.path.join("missing tool.exe")] { + let operation = operation(&fixture, context.clone(), json!({"toolPath":configured})); + let executor = Executor::new(|plan: &BuildProcessPlan| { + Ok(if plan.arguments == ["version"] { + output("go version go1.22.1 windows/amd64", "") + } else { + output(go_output(&fixture), "") + }) + }); + let result = execute_model(&operation, &Core, &executor, &Guard(|| Ok(()))); + if configured == tool { + assert!(result.is_ok()); + assert_eq!( + executor + .plans + .borrow() + .last() + .expect("model process") + .executable, + tool + ); + } else { + assert!(result.is_err()); + assert_eq!( + executor.plans.borrow().len(), + 1, + "only the required runtime was probed" + ); + } + } +} + +#[test] +fn native_executor_checks_cancellation_before_process_start() { + let plan = BuildProcessPlan { + executable: PathBuf::from("must-never-run"), + arguments: vec![], + environment: BTreeMap::new(), + directory: PathBuf::new(), + timeout: PROBE_TIMEOUT, + output_limit: PROBE_OUTPUT_LIMIT, + output_file: None, + }; + assert_eq!( + NativeBuildExecutor + .run(&plan, &Guard(|| Err("plugin.build.cancelled".into()))) + .err() + .as_deref(), + Some("plugin.build.cancelled") + ); +} + +#[test] +fn cleanup_failure_wins_over_concurrent_revoke_and_keeps_the_slot_owned() { + let fixture = Fixture::new(); + fixture.file("go.mod", "module example.test/app"); + let declaration = manifest("build-command-stdout.plugin.json"); + let (_, _, context) = context(&fixture, &declaration, "go"); + let operations = BuildOperations::default(); + let review = operations + .insert(operation(&fixture, context.clone(), json!({}))) + .expect("prepare"); + let running = operations + .claim("main", &review.operation_id) + .expect("claim"); + let revoked = Cell::new(false); + let executor = Executor::new(|_: &BuildProcessPlan| { + revoked.set(true); + Err("plugin.build.cleanupFailed".into()) + }); + let guard = Guard(|| { + if revoked.get() { + Err("plugin.build.cancelled".into()) + } else { + Ok(()) + } + }); + assert_eq!( + execute_model(&running.0, &Core, &executor, &guard) + .err() + .as_deref(), + Some("plugin.build.cleanupFailed") + ); + running.0.retain_failed_cleanup(()); + let control = running.0.clone(); + drop(running); + assert!(!control.finished()); + assert!(operations + .insert(operation(&fixture, context, json!({}))) + .is_err()); +} + +#[test] +fn cancelled_operation_refuses_a_native_start_permit() { + let fixture = Fixture::new(); + fixture.file("go.mod", "module example.test/app"); + let declaration = manifest("build-command-stdout.plugin.json"); + let (_, _, context) = context(&fixture, &declaration, "go"); + let operation = operation(&fixture, context, json!({})); + drop(operation.start_permit().expect("live permit")); + operation.cancel(); + assert!(operation.start_permit().is_err()); +} + +#[test] +fn redetection_and_close_reject_late_command_model_publication() { + let fixture = Fixture::new(); + fixture.file("go.mod", "module example.test/app"); + let declaration = manifest("build-command-stdout.plugin.json"); + let (mut registry, packages, context) = context(&fixture, &declaration, "go"); + registry + .refresh(&Core, &packages, &Options, "main", &fixture.workspace()) + .expect("redetect"); + assert!(!registry.context_is_current(&context)); + assert!(registry + .apply_command_model(&Core, &context, json!({}), "old-fingerprint") + .is_err()); + let current = registry + .model_context("main", &fixture.workspace(), &context.plugin_id, "go", ".") + .expect("current"); + registry.close_window("main"); + assert!(!registry.context_is_current(¤t)); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build_registry.rs b/windows/tauri/src-tauri/src/plugin_host/build_registry.rs new file mode 100644 index 0000000000..946f984dc0 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build_registry.rs @@ -0,0 +1,1321 @@ +//! Windows-side build session registry for declarative build systems. +//! +//! One registry holds per-window build facts (instances, static models, +//! option descriptions, sync digests) keyed by window label and workspace +//! root. Every semantic interpretation stays in Core: this module only +//! shapes `build.*` requests, applies the host storage rules (values shared +//! per build system plus per-instance explicit-empty markers), and refuses +//! writes whose window has already moved to another workspace. Package +//! identity is re-checked against the injected source before every write, +//! so a disabled, replaced, or quarantined package never keeps answering +//! from a stale session. Detection and option resolution run no processes; +//! the capability declaration stays closed until the full milestone. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +mod settings_reload; + +/// One verified package as the build pipeline should see it. The version +/// comes from the same trust check as the manifest, so a session can detect +/// a package replacement between operations. +#[derive(Debug, Clone, PartialEq)] +pub struct BuildPackageInput { + pub plugin_id: String, + pub version: String, + pub manifest: Value, + /// Canonical installation identity plus live whole-package digest, + /// including same-version replacements and package relocations. + pub content_identity: String, +} + +/// Executes one Core `build.*` command through the shared envelope. +pub trait BuildCoreExecutor: Send + Sync { + fn execute(&self, command: &str, payload: Value) -> Result; +} + +/// The verified build packages. Production re-verifies trust against the +/// live bytes (M1's launch gate) on every call; tests inject fixture +/// manifests, which is the only sanctioned way to exercise build +/// declarations while the capability is closed. +pub trait BuildPackageSource: Send + Sync { + fn accepted_packages(&self) -> Vec; + + /// The current verified package for one plugin, or `None` when it is no + /// longer accepted, enabled, or trusted. + fn package(&self, plugin_id: &str) -> Option; +} + +/// Persists build option values per workspace. Values are shared per build +/// system; explicit empty multi-select choices are kept as per-instance +/// markers so sibling instances never lose their own selections. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredBuildOptions { + pub systems: BTreeMap, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredSystemOptions { + /// Shared stored values by option ID, in the `build.detect` shape. + pub values: BTreeMap, + /// Instance roots whose user explicitly cleared a multi-select option; + /// the marker wins over a non-empty default until reset or re-edit. + pub empty_markers: BTreeMap>, +} + +pub trait BuildOptionsPersistence: Send + Sync { + fn load(&self, workspace_root: &str) -> Result; + fn save(&self, workspace_root: &str, options: &StoredBuildOptions) -> Result<(), String>; +} + +/// One detected build-system instance with its host-side session state. +#[derive(Debug, Clone)] +struct BuildSession { + revision: u64, + plugin_id: String, + build_system_id: String, + root: String, + /// Package version the session was built from; a later mismatch means + /// the package was replaced and the session must not be trusted. + version: String, + /// The manifest of the owning package, for plan-shaped Core calls. + manifest: Value, + content_identity: String, + /// Core's detect instance, including the static model when it has one. + instance: Value, + /// `build.resolveOptions` description: kinds, choices, effective values. + options_description: Value, + /// Effective values (markers applied) as a plain map for sync requests. + effective_options: BTreeMap, + /// Model-declared choice values, carried into option resolution. + model_values: BTreeMap>, + /// Last `build.syncFingerprint` digest; `None` before the first check. + sync_digest: Option, + /// True when a later digest differs from the one the model was built + /// with; the model stays visible until the user refreshes. + stale: bool, +} + +impl BuildSession { + fn matches_package(&self, package: &BuildPackageInput) -> bool { + self.plugin_id == package.plugin_id + && self.version == package.version + && self.content_identity == package.content_identity + && self.manifest == package.manifest + } +} + +#[derive(Debug, Default, Clone)] +struct WindowState { + settings_reloads: BTreeSet, + settings_revisions: BTreeMap, + execution_suspended: usize, + /// A queued whole-window detection also depends on plugins that have not + /// produced sessions yet, so revocation must invalidate this request too. + refresh_pending: bool, + workspace_root: Option, + generation: u64, + /// Keyed by (plugin ID, build system ID, instance root). + sessions: BTreeMap<(String, String, String), BuildSession>, + issues: Vec, +} + +/// Immutable snapshot for one window: sessions and issues as JSON. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildView { + pub generation: u64, + pub workspace_root: String, + pub instances: Vec, + pub issues: Vec, +} + +#[derive(Debug, Default)] +pub struct BuildRegistry { + windows: HashMap, +} + +/// Admission identity for a passive refresh queued behind native cleanup. +/// Reserving it happens before the first await, so a later close or request +/// can invalidate work that has not entered the blocking pool yet. +pub(crate) struct BuildRefreshTicket { + window_label: String, + workspace_root: String, + generation: u64, +} + +/// Frozen semantic input for an explicit model load. The host operation +/// coordinator owns execution and checks this snapshot again before writes. +#[derive(Debug, Clone)] +pub(crate) struct BuildModelContext { + pub window_label: String, + pub workspace_root: String, + pub generation: u64, + pub plugin_id: String, + pub build_system_id: String, + pub root: String, + pub package: BuildPackageInput, + pub options: BTreeMap, + pub model: Value, + pub model_stale: bool, + pub(crate) revision: u64, +} + +impl BuildRegistry { + pub(crate) fn function_scope_revision( + &self, + label: &str, + root: &str, + plugin: &str, + ) -> Result { + let generation = self.function_generation(label, root)?; + self.check_read_snapshot(label, root, generation)?; + self.settings_scope_revision(label, root, plugin) + } + + pub(crate) fn settings_scope_revision( + &self, + label: &str, + root: &str, + plugin: &str, + ) -> Result { + let Some(window) = self + .windows + .get(label) + .filter(|window| window.workspace_root.as_deref() == Some(root)) + else { + return Ok(0); + }; + if window.settings_reloads.contains(plugin) { + return Err("plugin.build.staleContext".into()); + } + Ok(window.settings_revisions.get(plugin).copied().unwrap_or(0)) + } + + pub(crate) fn function_generation(&self, label: &str, root: &str) -> Result { + let generation = self + .windows + .get(label) + .ok_or("plugin.build.staleContext")? + .generation; + self.check_read_snapshot(label, root, generation)?; + Ok(generation) + } + pub fn new() -> Self { + Self::default() + } + + /// Passive consumers must not interpret a reserved refresh's empty view + /// as an authoritative absence of ownership. + pub(crate) fn check_read_snapshot( + &self, + window: &str, + workspace: &str, + generation: u64, + ) -> Result<(), String> { + if self.windows.get(window).is_some_and(|state| { + state.execution_suspended == 0 + && !state.refresh_pending + && state.generation == generation + && state.workspace_root.as_deref() == Some(workspace) + }) { + Ok(()) + } else { + Err("plugin.build.staleContext".into()) + } + } + + pub(crate) fn model_context( + &self, + window_label: &str, + workspace_root: &str, + plugin_id: &str, + build_system_id: &str, + root: &str, + ) -> Result { + let window = self + .windows + .get(window_label) + .ok_or("plugin.build.staleContext")?; + if window.execution_suspended > 0 + || window.settings_reloads.contains(plugin_id) + || window.workspace_root.as_deref() != Some(workspace_root) + { + return Err("plugin.build.staleContext".into()); + } + let session = window + .sessions + .get(&(plugin_id.into(), build_system_id.into(), root.into())) + .ok_or("plugin.build.staleContext")?; + Ok(BuildModelContext { + window_label: window_label.into(), + workspace_root: workspace_root.into(), + generation: window.generation, + plugin_id: plugin_id.into(), + build_system_id: build_system_id.into(), + root: root.into(), + package: BuildPackageInput { + plugin_id: plugin_id.into(), + version: session.version.clone(), + manifest: session.manifest.clone(), + content_identity: session.content_identity.clone(), + }, + options: session.effective_options.clone(), + model: session.instance["model"].clone(), + model_stale: session.stale, + revision: session.revision, + }) + } + + pub(crate) fn context_is_current(&self, context: &BuildModelContext) -> bool { + let Some(window) = self.windows.get(&context.window_label) else { + return false; + }; + if window.execution_suspended > 0 + || window.settings_reloads.contains(&context.plugin_id) + || window.generation != context.generation + || window.workspace_root.as_deref() != Some(context.workspace_root.as_str()) + { + return false; + } + window + .sessions + .get(&( + context.plugin_id.clone(), + context.build_system_id.clone(), + context.root.clone(), + )) + .is_some_and(|session| { + session.version == context.package.version + && session.revision == context.revision + && session.stale == context.model_stale + && session.content_identity == context.package.content_identity + && session.manifest == context.package.manifest + && session.effective_options == context.options + }) + } + + /// Apply only the result of the exact operation snapshot. Model choices + /// and the sync baseline still come from Core, just as on the static path. + pub(crate) fn apply_command_model( + &mut self, + core: &dyn BuildCoreExecutor, + context: &BuildModelContext, + model: Value, + fingerprint_before: &str, + ) -> Result { + if !self.context_is_current(context) { + return Err("plugin.build.staleContext".into()); + } + let window = self + .windows + .get_mut(&context.window_label) + .ok_or("plugin.build.staleContext")?; + let key = ( + context.plugin_id.clone(), + context.build_system_id.clone(), + context.root.clone(), + ); + let previous = window + .sessions + .get(&key) + .ok_or("plugin.build.staleContext")?; + let mut instance = previous.instance.clone(); + instance["model"] = model; + instance + .as_object_mut() + .ok_or("invalid build instance")? + .remove("modelError"); + let mut session = resolve_session( + core, + &instance, + Some(&context.package), + &context.options, + BTreeSet::new(), + )?; + session.revision = previous.revision.saturating_add(1); + let after = sync_fingerprint( + core, + &context.workspace_root, + &session, + &context.package.manifest, + )?; + session.stale = fingerprint_before != after; + session.sync_digest = Some(after); + window.sessions.insert(key, session); + Ok(self.view(&context.window_label)) + } + + pub(crate) fn model_fingerprint( + &self, + core: &dyn BuildCoreExecutor, + context: &BuildModelContext, + ) -> Result { + if !self.context_is_current(context) { + return Err("plugin.build.staleContext".into()); + } + let session = &self.windows[&context.window_label].sessions[&( + context.plugin_id.clone(), + context.build_system_id.clone(), + context.root.clone(), + )]; + sync_fingerprint( + core, + &context.workspace_root, + session, + &context.package.manifest, + ) + } + + pub(crate) fn close_window(&mut self, window_label: &str) { + if let Some(window) = self.windows.get_mut(window_label) { + window.sessions.clear(); + window.settings_reloads.clear(); + window.issues.clear(); + window.workspace_root = None; + window.refresh_pending = false; + window.generation = window.generation.saturating_add(1); + } + } + + /// Invalidate before draining, not after it. A concurrent prepare/confirm + /// cannot obtain a fresh executable snapshot while refresh/close is waiting + /// for an older native owner to release its process tree. + pub(crate) fn suspend_execution(&mut self, window_label: &str) -> u64 { + let window = self.windows.entry(window_label.into()).or_default(); + window.execution_suspended = window.execution_suspended.saturating_add(1); + window.refresh_pending = false; + window.generation = window.generation.saturating_add(1); + window.generation + } + + pub(crate) fn resume_execution(&mut self, window_label: &str) { + if let Some(window) = self.windows.get_mut(window_label) { + window.execution_suspended = window.execution_suspended.saturating_sub(1); + } + } + + pub(crate) fn check_generation( + &self, + window_label: &str, + generation: u64, + ) -> Result<(), String> { + if self + .windows + .get(window_label) + .is_some_and(|window| window.generation == generation) + { + Ok(()) + } else { + Err("plugin.build.staleContext".into()) + } + } + + pub(crate) fn reserve_refresh( + &mut self, + window_label: &str, + workspace_root: &str, + ) -> BuildRefreshTicket { + let window = self.windows.entry(window_label.into()).or_default(); + window.generation = window.generation.saturating_add(1); + if window.workspace_root.as_deref() != Some(workspace_root) { + window.sessions.clear(); + window.issues.clear(); + } + // Record the requested workspace now. A close arriving before this + // request starts reading must invalidate it even on the first load. + window.workspace_root = Some(workspace_root.into()); + window.refresh_pending = true; + BuildRefreshTicket { + window_label: window_label.into(), + workspace_root: workspace_root.into(), + generation: window.generation, + } + } + + pub(crate) fn refresh_reserved( + &mut self, + core: &dyn BuildCoreExecutor, + packages: &dyn BuildPackageSource, + persistence: &dyn BuildOptionsPersistence, + ticket: &BuildRefreshTicket, + checkpoint: &dyn Fn() -> Result<(), String>, + ) -> Result { + self.check_generation(&ticket.window_label, ticket.generation)?; + if !self + .windows + .get(&ticket.window_label) + .is_some_and(|window| { + window.refresh_pending + && window.workspace_root.as_deref() == Some(ticket.workspace_root.as_str()) + }) + { + return Err("plugin.build.staleContext".into()); + } + let result = self.refresh_checked( + core, + packages, + persistence, + &ticket.window_label, + &ticket.workspace_root, + checkpoint, + ); + self.windows + .get_mut(&ticket.window_label) + .expect("reserved window") + .refresh_pending = false; + result + } + + /// Re-detects every instance of the workspace for one window. A changed + /// workspace root resets the window's sessions and bumps its generation + /// so responses already in flight are dropped by the frontend. + pub fn refresh( + &mut self, + core: &dyn BuildCoreExecutor, + packages: &dyn BuildPackageSource, + persistence: &dyn BuildOptionsPersistence, + window_label: &str, + workspace_root: &str, + ) -> Result { + self.refresh_checked( + core, + packages, + persistence, + window_label, + workspace_root, + &|| Ok(()), + ) + } + + fn refresh_checked( + &mut self, + core: &dyn BuildCoreExecutor, + packages: &dyn BuildPackageSource, + persistence: &dyn BuildOptionsPersistence, + window_label: &str, + workspace_root: &str, + checkpoint: &dyn Fn() -> Result<(), String>, + ) -> Result { + self.refresh_selected_checked( + core, + packages, + persistence, + window_label, + workspace_root, + checkpoint, + None, + ) + } + + /// Core sees every package for build-system ownership. A scoped reload + /// resolves and persists only the selected plugin's detected instances. + fn refresh_selected_checked( + &mut self, + core: &dyn BuildCoreExecutor, + packages: &dyn BuildPackageSource, + persistence: &dyn BuildOptionsPersistence, + window_label: &str, + workspace_root: &str, + checkpoint: &dyn Fn() -> Result<(), String>, + plugin: Option<&str>, + ) -> Result { + checkpoint()?; + { + let window = self.windows.entry(window_label.to_string()).or_default(); + let switched = window.workspace_root.is_some() + && window.workspace_root.as_deref() != Some(workspace_root); + if switched { + window.generation += 1; + window.sessions.clear(); + window.issues.clear(); + } + window.workspace_root = Some(workspace_root.to_string()); + } + + let manifests = packages.accepted_packages(); + let persisted = persistence.load(workspace_root)?; + let mut stored = persisted.clone(); + let mut changed = false; + let request_options: BTreeMap> = stored + .systems + .iter() + .map(|(system, options)| (system.clone(), options.values.clone())) + .collect(); + let response = core.execute( + "build.detect", + json!({ + "workspaceRoot": workspace_root, + "plugins": manifests + .iter() + .map(|package| json!({ "manifest": package.manifest })) + .collect::>(), + "options": request_options, + }), + )?; + let detection = outcome_value(&response, "build.detect")?; + let detection = &detection["detection"]; + + let mut issues = Vec::new(); + for rejected in detection["rejected"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + { + let mut issue = rejected.clone(); + // Core reports manifest rejections by request index; map back to + // the plugin ID so the UI can name the package. + if let Some(package) = rejected["index"] + .as_u64() + .and_then(|index| usize::try_from(index).ok()) + .and_then(|index| manifests.get(index)) + { + issue["pluginID"] = json!(package.plugin_id); + } + issue["kind"] = json!("rejected"); + issues.push(issue); + } + for shadowed in detection["shadowed"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + { + let mut issue = shadowed.clone(); + issue["kind"] = json!("shadowed"); + issues.push(issue); + } + + if let Some(plugin) = plugin { + issues.retain(|issue| issue["pluginID"] == plugin); + } + let mut sessions = BTreeMap::new(); + for instance in detection["instances"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + { + let plugin_id = instance["pluginID"].as_str().unwrap_or_default(); + if plugin.is_some_and(|plugin| plugin != plugin_id) { + continue; + } + let build_system_id = instance["buildSystemID"].as_str().unwrap_or_default(); + let root = instance["root"].as_str().unwrap_or_default(); + if plugin_id.is_empty() || build_system_id.is_empty() || root.is_empty() { + continue; + } + let package = manifests + .iter() + .find(|package| package.plugin_id == plugin_id); + let system = stored + .systems + .entry(build_system_id.to_string()) + .or_default(); + // Resolve against one snapshot of the shared values so a later + // instance never sees an earlier instance's dropped adjustment. + let mut session = resolve_session( + core, + instance, + package, + &system.values.clone(), + system.empty_markers.get(root).cloned().unwrap_or_default(), + )?; + // Detection discovers roots and model-dependent options. Bracket + // the authoritative model read with fingerprints: taking only a + // digest after detection could bless bytes changed after the + // model was read as its baseline and miss the first watcher event. + let before = sync_fingerprint(core, workspace_root, &session, &session.manifest)?; + if matches!( + instance["modelSource"].as_str(), + Some("static" | "hostIntegration") + ) { + let response = super::build::host_integration::read_passive_model( + core, + &session.manifest, + build_system_id, + workspace_root, + root, + &session.effective_options, + )?; + let mut loaded = instance.clone(); + loaded + .as_object_mut() + .expect("detected instance") + .remove("model"); + loaded + .as_object_mut() + .expect("detected instance") + .remove("modelError"); + if response["status"] == "ok" { + let outcome = outcome_value(&response, "build.decodeModel")?; + loaded["model"] = outcome["model"].clone(); + } else { + loaded["modelError"] = response["error"].clone(); + } + session = resolve_session( + core, + &loaded, + package, + &system.values, + system.empty_markers.get(root).cloned().unwrap_or_default(), + )?; + } + let after = sync_fingerprint(core, workspace_root, &session, &session.manifest)?; + session.stale = before != after; + session.sync_digest = Some(after); + sessions.insert( + ( + plugin_id.to_string(), + build_system_id.to_string(), + root.to_string(), + ), + session, + ); + } + + // A stored value only changes when no instance accepts it anymore; + // per-instance drops keep sibling-only selections in the shared + // store, and each instance filters them through its own model. + for (system_id, instances) in sessions_by_system(&sessions) { + let Some(system) = stored.systems.get_mut(system_id) else { + continue; + }; + let descriptions: Vec = instances + .iter() + .map(|session| session.options_description.clone()) + .collect(); + for (option_id, value) in fully_dropped_adjustments_from(&descriptions) { + system.values.insert(option_id.to_string(), value); + changed = true; + } + } + + // The source owns trust and lifecycle. Recheck complete identities, + // not just versions: a read must not publish after disable, quarantine, + // relocation, or a same-version replacement during Core decoding. + checkpoint()?; + if persistence.load(workspace_root)? != persisted { + return Err("plugin.build.staleContext".into()); + } + for package in &manifests { + if packages.package(&package.plugin_id).as_ref() != Some(package) { + return Err("plugin.build.packageChanged".into()); + } + } + if changed { + persistence.save(workspace_root, &stored)?; + } + checkpoint()?; + let window = self.windows.entry(window_label.to_string()).or_default(); + // Even a same-workspace refresh replaces the model/options snapshot. + // Preserve the first-generation contract, invalidate subsequent loads. + if !window.sessions.is_empty() { + window.generation += 1; + } + window.sessions = sessions; + window.issues = issues; + window.settings_reloads.clear(); + Ok(self.view(window_label)) + } + + /// Applies one user option edit. The candidate storage is validated + /// through Core before anything is persisted; a Core rejection, a save + /// failure, or a failed re-detection leaves the previous storage and + /// model untouched. `value: None` resets the option: the stored value + /// and every instance's empty marker for it are removed so the declared + /// default applies again. + #[allow(clippy::too_many_arguments)] + pub fn set_option( + &mut self, + core: &dyn BuildCoreExecutor, + packages: &dyn BuildPackageSource, + persistence: &dyn BuildOptionsPersistence, + window_label: &str, + workspace_root: &str, + plugin_id: &str, + build_system_id: &str, + root: &str, + option_id: &str, + value: Option, + ) -> Result { + let fresh = packages + .package(plugin_id) + .ok_or_else(|| format!("{plugin_id} is no longer an enabled plugin"))?; + let kind = { + let window = self + .windows + .get(window_label) + .ok_or_else(|| format!("window {window_label} has no build state"))?; + if window.workspace_root.as_deref() != Some(workspace_root) { + return Err(format!( + "build state for window {window_label} moved to another workspace" + )); + } + let session = window + .sessions + .get(&( + plugin_id.to_string(), + build_system_id.to_string(), + root.to_string(), + )) + .ok_or_else(|| format!("no build instance {build_system_id} at {root}"))?; + if !session.matches_package(&fresh) { + return Err(format!( + "{plugin_id} changed since the build window loaded it; reload" + )); + } + option_kind(&session.options_description, option_id) + .ok_or_else(|| format!("option {option_id} is not declared"))? + }; + + let previous = persistence.load(workspace_root)?; + let mut candidate = previous.clone(); + { + let system = candidate + .systems + .entry(build_system_id.to_string()) + .or_default(); + match value { + None => { + // Reset: the shared override and every instance's + // explicit empty marker go away, so the declared default + // applies again. + system.values.remove(option_id); + for markers in system.empty_markers.values_mut() { + markers.remove(option_id); + } + } + Some(value) if kind == "multiSelect" => { + let selection = value + .as_array() + .ok_or_else(|| format!("option {option_id} expects an array"))?; + let own: BTreeSet = selection.iter().filter_map(as_string).collect(); + // Sibling selections survive: the shared array is the + // union of what every instance still accepts, and this + // instance's view is derived from it plus its marker. + let siblings: BTreeSet = self + .windows + .get(window_label) + .into_iter() + .flat_map(|window| window.sessions.values()) + .filter(|other| { + other.build_system_id == build_system_id && other.root != root + }) + .filter_map(|other| other.effective_options.get(option_id).cloned()) + .filter_map(|value| string_list(&value)) + .flatten() + .collect(); + let markers = system.empty_markers.entry(root.to_string()).or_default(); + if own.is_empty() { + markers.insert(option_id.to_string()); + } else { + markers.remove(option_id); + } + let merged: Vec = siblings.union(&own).cloned().collect(); + if merged.is_empty() { + // Nothing selected anywhere: drop the shared entry so + // siblings fall back to defaults while this + // instance's marker keeps its explicit empty value. + system.values.remove(option_id); + } else { + system.values.insert(option_id.to_string(), json!(merged)); + } + } + Some(value) => { + // Non-array kinds keep the explicit value verbatim; an + // empty text value stays an explicit empty choice, not + // "unset". + system.values.insert(option_id.to_string(), value); + system + .empty_markers + .entry(root.to_string()) + .or_default() + .remove(option_id); + } + } + } + + // Core validation before any disk write: every instance of the + // system must resolve the candidate, and the edited option must + // survive for the edited instance. Values Core drops (wrong type, + // unknown choice) are a rejected edit, not a silent rewrite. + let mut validated = Vec::new(); + for session in self + .windows + .get(window_label) + .into_iter() + .flat_map(|window| window.sessions.values()) + .filter(|session| session.build_system_id == build_system_id) + { + let marked = marked_values(&candidate, build_system_id, session); + let description = resolve_options( + core, + &fresh.manifest, + build_system_id, + &marked, + &session.model_values, + )?; + if session.root == root + && description["dropped"] + .as_array() + .map(|dropped| dropped.contains(&json!(option_id))) + .unwrap_or(false) + { + return Err(format!( + "option {option_id} was rejected by Core for instance {root}" + )); + } + validated.push(description); + } + let system = candidate + .systems + .get_mut(build_system_id) + .expect("candidate system exists after the edit"); + for (option_id, value) in fully_dropped_adjustments_from(&validated) { + system.values.insert(option_id, value); + } + + // First and only disk write, then re-detect; a failed refresh rolls + // the storage back so the model and the file stay consistent. + if packages.package(plugin_id).as_ref() != Some(&fresh) { + return Err("plugin.build.packageChanged".into()); + } + persistence.save(workspace_root, &candidate)?; + self.refresh(core, packages, persistence, window_label, workspace_root) + .map_err(|error| { + if persistence.save(workspace_root, &previous).is_err() { + return format!("{error}; the previous build options could not be restored"); + } + error + }) + } + + /// Recomputes one instance's sync fingerprint and flags the model stale + /// when it changed. The package is re-verified first, and a replaced + /// package version refuses to answer from the old session. + pub fn sync_check( + &mut self, + core: &dyn BuildCoreExecutor, + packages: &dyn BuildPackageSource, + window_label: &str, + workspace_root: &str, + plugin_id: &str, + build_system_id: &str, + root: &str, + ) -> Result { + let key = ( + plugin_id.to_string(), + build_system_id.to_string(), + root.to_string(), + ); + let session = { + let window = self + .windows + .get(window_label) + .ok_or_else(|| format!("window {window_label} has no build state"))?; + if window.workspace_root.as_deref() != Some(workspace_root) { + return Err(format!( + "build state for window {window_label} moved to another workspace" + )); + } + window + .sessions + .get(&key) + .ok_or_else(|| { + format!( + "no build instance {build_system_id} at {root} in window {window_label}" + ) + })? + .clone() + }; + let fresh = packages + .package(plugin_id) + .ok_or_else(|| format!("{plugin_id} is no longer an enabled plugin"))?; + if fresh.version != session.version { + return Err(format!( + "{plugin_id} changed since the build window loaded it; reload" + )); + } + let replaced = !session.matches_package(&fresh); + + // Use the current verified manifest, including same-version reinstalls. + let digest = sync_fingerprint(core, workspace_root, &session, &fresh.manifest)?; + if packages.package(plugin_id).as_ref() != Some(&fresh) { + return Err("plugin.build.packageChanged".into()); + } + + let window = self + .windows + .get_mut(window_label) + .ok_or_else(|| format!("window {window_label} has no build state"))?; + let session = window + .sessions + .get_mut(&key) + .ok_or_else(|| format!("no build instance {build_system_id} at {root}"))?; + match &session.sync_digest { + Some(previous) if *previous != digest => session.stale = true, + _ => {} + } + if replaced { + // A sync may observe the current package to report staleness, but + // cannot adopt its identity for a model decoded by the old one. + // Preserve that snapshot until an explicit passive refresh/load. + session.stale = true; + session.revision = session.revision.saturating_add(1); + } + session.sync_digest = Some(digest); + Ok(self.view(window_label)) + } + + /// Drops every session of one plugin across all windows and bumps the + /// affected generations: the revocation path for disable, uninstall, + /// rollback, and same-version replacement. Late responses and stored + /// sessions of the removed plugin can no longer revive it. + pub fn drop_plugin(&mut self, plugin_id: &str) { + for window in self.windows.values_mut() { + window.settings_reloads.remove(plugin_id); + let before = window.sessions.len(); + window + .sessions + .retain(|(owner, _, _), _| owner != plugin_id); + if window.sessions.len() != before || window.refresh_pending { + window.generation += 1; + window.refresh_pending = false; + } + window + .issues + .retain(|issue| issue["pluginID"].as_str() != Some(plugin_id)); + } + } + + /// Clears one window's sessions when its workspace closed, so reopening + /// the same path cannot silently reuse the pre-close sessions. + pub fn close_workspace(&mut self, window_label: &str, workspace_root: &str) { + let Some(window) = self.windows.get_mut(window_label) else { + return; + }; + if window.workspace_root.as_deref() == Some(workspace_root) { + window.workspace_root = None; + window.refresh_pending = false; + window.sessions.clear(); + window.settings_reloads.clear(); + window.issues.clear(); + window.generation += 1; + } + } + + /// Snapshot of one window's build state. + pub fn view(&self, window_label: &str) -> BuildView { + let empty = WindowState::default(); + let window = self.windows.get(window_label).unwrap_or(&empty); + let instances = window + .sessions + .values() + .map(|session| { + let mut value = session.instance.clone(); + value["stale"] = json!(session.stale); + value["syncDigest"] = session + .sync_digest + .as_ref() + .map(|digest| json!(digest)) + .unwrap_or(Value::Null); + value["options"] = session.options_description["options"].clone(); + value["optionFingerprint"] = session.options_description["fingerprint"].clone(); + value + }) + .collect(); + BuildView { + generation: window.generation, + workspace_root: window.workspace_root.clone().unwrap_or_default(), + instances, + issues: window.issues.clone(), + } + } +} + +/// Reads the Core-owned file and option fingerprint for this exact model. +fn sync_fingerprint( + core: &dyn BuildCoreExecutor, + workspace_root: &str, + session: &BuildSession, + manifest: &Value, +) -> Result { + let projects: Vec<&str> = session.instance["model"]["projects"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(|project| project["path"].as_str()) + .collect(); + let response = core.execute( + "build.syncFingerprint", + json!({ + "manifest": manifest, + "buildSystemID": session.build_system_id, + "request": { + "workspaceRoot": workspace_root, + "root": session.root, + "projects": projects, + "options": session.effective_options, + }, + }), + )?; + let outcome = outcome_value(&response, "build.syncFingerprint")?; + outcome["sync"]["fingerprint"] + .as_str() + .map(str::to_string) + .ok_or_else(|| "build.syncFingerprint returned no fingerprint".to_string()) +} + +/// Builds one session: re-resolves options per instance so per-instance +/// markers, model choices, and dropped values are applied through Core. +fn resolve_session( + core: &dyn BuildCoreExecutor, + instance: &Value, + package: Option<&BuildPackageInput>, + shared_values: &BTreeMap, + markers: BTreeSet, +) -> Result { + let package = package + .ok_or_else(|| { + format!( + "build instance references unknown plugin {}", + instance["pluginID"].as_str().unwrap_or_default() + ) + })? + .clone(); + let build_system_id = instance["buildSystemID"] + .as_str() + .unwrap_or_default() + .to_string(); + let root = instance["root"].as_str().unwrap_or_default().to_string(); + let model_values = model_option_values(instance); + + let description = resolve_options( + core, + &package.manifest, + &build_system_id, + shared_values, + &model_values, + )?; + // Markers may only override options Core still describes as + // multi-select; a declaration change turns the marker into a no-op. + let mut marked = shared_values.clone(); + for option in description["options"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + { + let is_multi = option["kind"].as_str() == Some("multiSelect"); + let is_marked = option["id"].as_str().is_some_and(|id| markers.contains(id)); + if is_multi && is_marked { + if let Some(id) = option["id"].as_str() { + marked.insert(id.to_string(), json!([])); + } + } + } + let description = if marked != *shared_values { + resolve_options( + core, + &package.manifest, + &build_system_id, + &marked, + &model_values, + )? + } else { + description + }; + + Ok(BuildSession { + revision: 0, + plugin_id: instance["pluginID"] + .as_str() + .unwrap_or_default() + .to_string(), + build_system_id: build_system_id.clone(), + root: root.clone(), + version: package.version, + manifest: package.manifest, + content_identity: package.content_identity, + instance: instance.clone(), + effective_options: effective_map(&description), + options_description: description, + model_values, + sync_digest: None, + stale: false, + }) +} + +/// Stored values with one session's explicit-empty markers applied, using +/// the kinds Core described for that session. +fn marked_values( + stored: &StoredBuildOptions, + build_system_id: &str, + session: &BuildSession, +) -> BTreeMap { + let mut values = stored + .systems + .get(build_system_id) + .map(|system| system.values.clone()) + .unwrap_or_default(); + let markers = stored + .systems + .get(build_system_id) + .and_then(|system| system.empty_markers.get(&session.root)) + .cloned() + .unwrap_or_default(); + if markers.is_empty() { + return values; + } + for option in session.options_description["options"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + { + let is_multi = option["kind"].as_str() == Some("multiSelect"); + let is_marked = option["id"].as_str().is_some_and(|id| markers.contains(id)); + if is_multi && is_marked { + if let Some(id) = option["id"].as_str() { + values.insert(id.to_string(), json!([])); + } + } + } + values +} + +fn resolve_options( + core: &dyn BuildCoreExecutor, + manifest: &Value, + build_system_id: &str, + values: &BTreeMap, + model_values: &BTreeMap>, +) -> Result { + let response = core.execute( + "build.resolveOptions", + json!({ + "manifest": manifest, + "buildSystemID": build_system_id, + "request": { + "options": values, + "modelValues": model_values, + }, + }), + )?; + let outcome = outcome_value(&response, "build.resolveOptions")?; + Ok(outcome["options"].clone()) +} + +/// Effective stored values for options that every instance of the system +/// dropped: Core describes what it still accepts (or the default), and the +/// host re-stores that so the next detection sees the same answer. Options +/// at least one instance still accepts stay untouched, so sibling-only +/// selections survive in the shared store. +fn fully_dropped_adjustments_from(descriptions: &[Value]) -> Vec<(String, Value)> { + let Some(first) = descriptions.first() else { + return Vec::new(); + }; + let Some(dropped) = first["dropped"].as_array() else { + return Vec::new(); + }; + dropped + .iter() + .filter_map(|id| id.as_str()) + .filter(|id| { + descriptions.iter().all(|description| { + description["dropped"] + .as_array() + .map(|list| list.contains(&json!(id))) + .unwrap_or(false) + }) + }) + .filter_map(|id| { + descriptions + .iter() + .find_map(|description| { + description["options"] + .as_array()? + .iter() + .find(|option| option["id"].as_str() == Some(id)) + .map(|option| option["value"].clone()) + }) + .map(|value| (id.to_string(), value)) + }) + .collect() +} + +/// Groups sessions by build-system ID, in deterministic key order. +fn sessions_by_system<'a>( + sessions: &'a BTreeMap<(String, String, String), BuildSession>, +) -> BTreeMap<&'a str, Vec<&'a BuildSession>> { + let mut grouped: BTreeMap<&str, Vec<&BuildSession>> = BTreeMap::new(); + for session in sessions.values() { + grouped + .entry(session.build_system_id.as_str()) + .or_default() + .push(session); + } + grouped +} + +fn effective_map(description: &Value) -> BTreeMap { + description["options"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(|option| { + option["id"] + .as_str() + .map(|id| (id.to_string(), option["value"].clone())) + }) + .collect() +} + +fn option_kind(description: &Value, option_id: &str) -> Option { + description["options"] + .as_array()? + .iter() + .find(|option| option["id"].as_str() == Some(option_id))?["kind"] + .as_str() + .map(str::to_string) +} + +/// Model-declared choice values for `choicesFromModel` options. +fn model_option_values(instance: &Value) -> BTreeMap> { + instance["model"]["optionValues"] + .as_object() + .map(|values| { + values + .iter() + .filter_map(|(id, list)| string_list(list).map(|values| (id.clone(), values))) + .collect() + }) + .unwrap_or_default() +} + +fn string_list(value: &Value) -> Option> { + value + .as_array() + .map(|values| values.iter().filter_map(as_string).collect()) +} + +fn as_string(value: &Value) -> Option { + value.as_str().map(str::to_string) +} + +/// Unwraps the shared `{status, }` envelope; rejected declarations and +/// requests surface as errors with their stable code. +fn outcome_value(response: &Value, command: &str) -> Result { + match response["status"].as_str() { + Some("ok") => Ok(response.clone()), + Some("rejected") => { + let code = response["error"]["code"].as_str().unwrap_or("unknown"); + let message = response["error"]["message"].as_str().unwrap_or("rejected"); + Err(format!("{command} rejected: {code}: {message}")) + } + _ => Err(format!("{command} returned an unexpected status")), + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build_registry/settings_reload.rs b/windows/tauri/src-tauri/src/plugin_host/build_registry/settings_reload.rs new file mode 100644 index 0000000000..b22418c60d --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build_registry/settings_reload.rs @@ -0,0 +1,109 @@ +//! A settings reload revokes only one plugin's models in one build window. +//! Existing operation draining and Core detection remain the owners. +use super::*; + +impl BuildRegistry { + pub(crate) fn settings_reload_pending(&self, label: &str, root: &str, plugin: &str) -> bool { + self.windows.get(label).is_some_and(|window| { + window.workspace_root.as_deref() == Some(root) + && window.settings_reloads.contains(plugin) + }) + } + pub(crate) fn begin_settings_reload( + &mut self, + label: &str, + root: &str, + plugin: &str, + ) -> Result { + let window = self + .windows + .get_mut(label) + .ok_or("plugin.settings.staleContext")?; + if window.workspace_root.as_deref() != Some(root) + || window.execution_suspended > 0 + || window.refresh_pending + { + return Err("plugin.settings.staleContext".into()); + } + // Invalidate old publications before taking the operation drain snapshot. + if window.settings_reloads.insert(plugin.into()) { + let revision = window.settings_revisions.entry(plugin.into()).or_default(); + *revision = revision.saturating_add(1); + for session in window + .sessions + .values_mut() + .filter(|session| session.plugin_id == plugin) + { + session.revision = session.revision.saturating_add(1); + } + } + Ok(window.generation) + } + + pub(crate) fn finish_settings_reload(&mut self, label: &str, generation: u64, plugin: &str) { + if let Some(window) = self + .windows + .get_mut(label) + .filter(|window| window.generation == generation) + { + window.settings_reloads.remove(plugin); + } + } + + pub(crate) fn reload_settings( + &mut self, + core: &dyn BuildCoreExecutor, + packages: &dyn BuildPackageSource, + persistence: &dyn BuildOptionsPersistence, + label: &str, + root: &str, + generation: u64, + plugin: &str, + checkpoint: &dyn Fn() -> Result<(), String>, + ) -> Result { + self.check_generation(label, generation)?; + let previous = self + .windows + .get(label) + .ok_or("plugin.settings.staleContext")?; + if previous.workspace_root.as_deref() != Some(root) + || !previous.settings_reloads.contains(plugin) + { + return Err("plugin.settings.staleContext".into()); + } + // Reuse detection on a candidate registry. Other plugins' sessions, + // model revisions and generation stay intact, including other windows. + let mut candidate = BuildRegistry::new(); + candidate.windows.insert(label.into(), previous.clone()); + candidate.refresh_selected_checked( + core, + packages, + persistence, + label, + root, + checkpoint, + Some(plugin), + )?; + checkpoint()?; + let next = candidate + .windows + .remove(label) + .ok_or("plugin.settings.staleContext")?; + let current = self + .windows + .get_mut(label) + .ok_or("plugin.settings.staleContext")?; + let mut sessions = next.sessions; + for (key, session) in &mut sessions { + if let Some(previous) = current.sessions.get(key) { + session.revision = previous.revision.saturating_add(1); + } + } + current.sessions.retain(|(id, _, _), _| id != plugin); + current.sessions.extend(sessions); + current.issues.retain(|issue| issue["pluginID"] != plugin); + current.issues.extend(next.issues); + current.settings_reloads.remove(plugin); + Ok(self.view(label)) + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build_resolver.rs b/windows/tauri/src-tauri/src/plugin_host/build_resolver.rs new file mode 100644 index 0000000000..591dce2690 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build_resolver.rs @@ -0,0 +1,1105 @@ +//! Resolves Core-ordered build executable candidates on Windows. +//! +//! Rust Core owns candidate ordering, setting validation, version parsing, and +//! build-system semantics. This adapter only turns an `invocationPlan` into +//! real Windows paths and candidate-local environments. It never starts a +//! process: callers execute each returned probe through the shared bounded +//! process runner, pass its output to `build.checkToolVersion`, and resume the +//! session with that deterministic answer. + +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::fmt; +use std::path::{Component, Path, PathBuf}; + +#[cfg(windows)] +use std::os::windows::fs::MetadataExt; + +#[cfg(windows)] +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + +/// A host-managed runtime selected by the existing Windows runtime settings. +/// +/// `environment` carries runtime-owned values such as `JAVA_HOME`. The +/// executable directory is added to PATH by this resolver. Explicit inputs +/// are terminal: a stale user selection must not silently switch runtimes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RuntimeExecutableInput { + pub(crate) executable: PathBuf, + pub(crate) environment: BTreeMap, + pub(crate) explicit: bool, +} + +impl RuntimeExecutableInput { + pub(crate) fn discovered(executable: PathBuf) -> Self { + Self { + executable, + environment: BTreeMap::new(), + explicit: false, + } + } +} + +/// Stateless production adapter plus one frozen environment/runtime snapshot. +/// +/// Construct one per operation. That prevents a settings or environment change +/// halfway through resolution from changing the meaning of later candidates. +#[derive(Debug, Clone)] +pub(crate) struct WindowsBuildToolResolver { + environment: BTreeMap, + runtimes: BTreeMap, + discovery_root: Option, +} + +impl WindowsBuildToolResolver { + /// Passive cwd validation shared by the model planner; this performs no + /// executable discovery or version probe. + pub(crate) fn instance_directory( + invocation: &Value, + workspace: &Path, + ) -> Result { + Self { + environment: BTreeMap::new(), + runtimes: BTreeMap::new(), + discovery_root: None, + } + .start(invocation, workspace) + .map(|session| session.instance_root) + } + + /// Passive native path proof shared with Provider bindings. Core keeps + /// the workspace-relative root in its plans; hosts also retain the real + /// directory identity without rewriting Provider request parameters. + pub(crate) fn instance_root_directory( + root: &str, + workspace: &Path, + ) -> Result { + let workspace = absolute_existing_directory(workspace, "workspace root")?; + let relative = plan_relative_path(root, "instance root")?; + let path = workspace.join(relative); + reject_reparse_components(&workspace, &path)?; + let directory = absolute_existing_directory(&path, "build instance root")?; + if !path_is_within(&directory, &workspace) { + return Err(BuildResolutionError::project_escape( + "Provider instance escaped its workspace", + )); + } + Ok(directory) + } + /// Option-owned environment is frozen before both probes and execution. + pub(crate) fn with_overrides(mut self, values: BTreeMap) -> Self { + for (key, value) in values { + environment_set(&mut self.environment, key, value); + } + self + } + + /// Freeze relative discovery before an operation can wait for consent. + pub(crate) fn freeze_discovery(mut self) -> Result { + self.discovery_root = Some(std::env::current_dir().map_err(|error| { + BuildResolutionError::invalid_plan(format!( + "host tool discovery directory could not be resolved: {error}" + )) + })?); + Ok(self) + } + + /// Uses the process environment and caller-supplied runtime preferences. + /// Runtime settings stay owned by the existing Windows runtime manager; + /// this module deliberately has no persistence of its own. + pub(crate) fn from_current_process(runtimes: BTreeMap) -> Self { + Self { + environment: std::env::vars().collect(), + runtimes, + discovery_root: None, + } + } + + #[cfg(test)] + pub(crate) fn with_environment( + environment: BTreeMap, + runtimes: BTreeMap, + ) -> Self { + Self { + environment, + runtimes, + discovery_root: None, + } + } + + /// Validates the Core plan and freezes workspace/instance real paths. + pub(crate) fn start( + &self, + invocation: &Value, + workspace_root: &Path, + ) -> Result { + let plan: InvocationPlan = serde_json::from_value(invocation.clone()).map_err(|error| { + BuildResolutionError::invalid_plan(format!("build invocation plan is invalid: {error}")) + })?; + let workspace_root = absolute_existing_directory(workspace_root, "workspace root")?; + let instance_root = Self::instance_root_directory(&plan.root, &workspace_root)?; + let configured_setting = plan + .executables + .iter() + .chain( + plan.runtime_dependencies + .iter() + .flat_map(|runtime| runtime.executables.iter()), + ) + .any(ExecutableCandidate::has_configured_setting); + // Relative discovery inputs belong to the host's current directory, + // never the workspace that a later probe or build runs in. + let discovery_root = self + .discovery_root + .clone() + .map(Ok) + .unwrap_or_else(std::env::current_dir) + .map_err(|error| { + BuildResolutionError::invalid_plan(format!( + "host tool discovery directory could not be resolved: {error}" + )) + })?; + Ok(BuildResolutionSession { + plan, + workspace_root, + instance_root, + discovery_root, + environment: self.environment.clone(), + runtime_executables: BTreeMap::new(), + runtime_index: 0, + runtime_candidate_index: 0, + tool_candidate_index: 0, + pending: None, + problems: Vec::new(), + configured_setting, + }) + } + + /// Advances resolution until a probe is needed or a tool is selected. + /// + /// The first call passes `None`. A `Probe` result must be followed by one + /// call carrying the matching outcome; extra or missing outcomes are a + /// protocol error. No filesystem state other than executable existence is + /// retained between calls. + pub(crate) fn advance( + &self, + session: &mut BuildResolutionSession, + outcome: Option, + ) -> Result { + self.consume_probe(session, outcome)?; + loop { + if session.runtime_index < session.plan.runtime_dependencies.len() { + if let Some(step) = self.advance_runtime(session)? { + return Ok(step); + } + continue; + } + return self.advance_tool(session); + } + } + + fn consume_probe( + &self, + session: &mut BuildResolutionSession, + outcome: Option, + ) -> Result<(), BuildResolutionError> { + let Some(pending) = session.pending.take() else { + if outcome.is_some() { + return Err(BuildResolutionError::invalid_plan( + "a version outcome was supplied without a pending probe", + )); + } + return Ok(()); + }; + let outcome = outcome.ok_or_else(|| { + BuildResolutionError::invalid_plan("the pending version probe has no outcome") + })?; + match outcome { + VersionProbeOutcome::Accepted { version } => match pending.target { + PendingTarget::Runtime { id } => { + session.environment = pending.selection.environment; + session + .runtime_executables + .insert(id, pending.selection.executable); + session.runtime_index += 1; + session.runtime_candidate_index = 0; + } + PendingTarget::Tool => { + session.pending = Some(PendingProbe { + target: PendingTarget::ResolvedTool { version }, + selection: pending.selection, + minimum_version: pending.minimum_version, + }); + } + PendingTarget::ResolvedTool { .. } => { + return Err(BuildResolutionError::invalid_plan( + "a completed build-tool probe was resumed twice", + )); + } + }, + VersionProbeOutcome::Rejected { version } => { + let problem = match version { + Some(version) => format!( + "{} reported version {version}; {} or newer is required", + pending.selection.executable.display(), + pending.minimum_version + ), + None => format!( + "{} did not report a usable version; {} or newer is required", + pending.selection.executable.display(), + pending.minimum_version + ), + }; + if pending.selection.terminal { + return Err(BuildResolutionError::configured(problem)); + } + session.problems.push(problem); + } + VersionProbeOutcome::Failed { message } => { + let problem = format!( + "version probe for {} failed: {message}", + pending.selection.executable.display() + ); + if pending.selection.terminal { + return Err(BuildResolutionError::configured(problem)); + } + session.problems.push(problem); + } + } + Ok(()) + } + + fn advance_runtime( + &self, + session: &mut BuildResolutionSession, + ) -> Result, BuildResolutionError> { + let dependency = session.plan.runtime_dependencies[session.runtime_index].clone(); + let sources = self.runtime_sources(&dependency, &session.environment); + while session.runtime_candidate_index < sources.len() { + let source = sources[session.runtime_candidate_index].clone(); + session.runtime_candidate_index += 1; + let Some(mut selection) = self.resolve_source(session, source)? else { + continue; + }; + prepend_path(&mut selection.environment, &selection.executable)?; + if selection.executes_project_code { + session.environment = selection.environment; + session + .runtime_executables + .insert(dependency.id.clone(), selection.executable); + session.runtime_index += 1; + session.runtime_candidate_index = 0; + return Ok(None); + } + if let Some((arguments, minimum_version)) = dependency.probe() { + let probe = VersionProbePlan::new( + selection.clone(), + arguments, + Some(dependency.id.clone()), + minimum_version.clone(), + ); + session.pending = Some(PendingProbe { + target: PendingTarget::Runtime { + id: dependency.id.clone(), + }, + selection, + minimum_version, + }); + return Ok(Some(BuildResolutionStep::Probe(probe))); + } + session.environment = selection.environment; + session + .runtime_executables + .insert(dependency.id.clone(), selection.executable); + session.runtime_index += 1; + session.runtime_candidate_index = 0; + return Ok(None); + } + let name = dependency.display_name.as_deref().unwrap_or(&dependency.id); + Err(BuildResolutionError::unavailable( + format!("build runtime {name} is not available"), + session.configured_setting, + &session.problems, + )) + } + + fn advance_tool( + &self, + session: &mut BuildResolutionSession, + ) -> Result { + if let Some(PendingProbe { + target: PendingTarget::ResolvedTool { version }, + selection, + .. + }) = session.pending.take() + { + return Ok(BuildResolutionStep::Resolved(ResolvedBuildTool { + executable: selection.executable, + environment: selection.environment, + executes_project_code: selection.executes_project_code, + version, + runtime_executables: session.runtime_executables.clone(), + })); + } + while session.tool_candidate_index < session.plan.executables.len() { + let candidate = session.plan.executables[session.tool_candidate_index].clone(); + session.tool_candidate_index += 1; + let Some(mut selection) = self.resolve_candidate(session, candidate)? else { + continue; + }; + if selection.terminal { + prepend_path(&mut selection.environment, &selection.executable)?; + } + if selection.executes_project_code { + return Ok(BuildResolutionStep::Resolved(ResolvedBuildTool { + executable: selection.executable, + environment: selection.environment, + executes_project_code: true, + version: None, + runtime_executables: session.runtime_executables.clone(), + })); + } + if let Some((arguments, minimum_version)) = session.plan.probe() { + let probe = VersionProbePlan::new( + selection.clone(), + arguments, + None, + minimum_version.clone(), + ); + session.pending = Some(PendingProbe { + target: PendingTarget::Tool, + selection, + minimum_version, + }); + return Ok(BuildResolutionStep::Probe(probe)); + } + return Ok(BuildResolutionStep::Resolved(ResolvedBuildTool { + executable: selection.executable, + environment: selection.environment, + executes_project_code: false, + version: None, + runtime_executables: session.runtime_executables.clone(), + })); + } + Err(BuildResolutionError::unavailable( + format!( + "build tool {} is not available", + session.plan.build_system_id + ), + session.configured_setting, + &session.problems, + )) + } + + fn runtime_sources( + &self, + dependency: &RuntimeDependencyPlan, + environment: &BTreeMap, + ) -> Vec { + if !dependency.host_managed { + return dependency + .executables + .iter() + .cloned() + .map(ResolutionSource::Candidate) + .collect(); + } + let mut sources = Vec::new(); + if let Some(input) = self.runtimes.get(&dependency.id) { + sources.push(ResolutionSource::Direct { + executable: input.executable.clone(), + environment: input.environment.clone(), + terminal: input.explicit, + }); + if input.explicit { + return sources; + } + } + if dependency.id.eq_ignore_ascii_case("java") { + if let Some(home) = environment_value(environment, "JAVA_HOME") { + sources.push(ResolutionSource::Direct { + executable: PathBuf::from(home).join("bin").join("java.exe"), + environment: BTreeMap::from([("JAVA_HOME".into(), home.to_string())]), + terminal: false, + }); + } + } + sources.push(ResolutionSource::Candidate(ExecutableCandidate::Command { + command: dependency.id.clone(), + })); + sources + } + + fn resolve_source( + &self, + session: &BuildResolutionSession, + source: ResolutionSource, + ) -> Result, BuildResolutionError> { + match source { + ResolutionSource::Candidate(candidate) => self.resolve_candidate(session, candidate), + ResolutionSource::Direct { + executable, + environment, + terminal, + } => { + let mut selected_environment = session.environment.clone(); + for (key, value) in environment { + environment_set(&mut selected_environment, key, value); + } + let discovered = discovery_path(&session.discovery_root, &executable); + if discovered + .as_ref() + .is_none_or(|executable| !is_spawnable_windows_file(executable)) + { + if terminal { + return Err(BuildResolutionError::configured(format!( + "configured runtime {} is not a Windows executable file", + executable.display() + ))); + } + return Ok(None); + } + Ok(Some(SelectedCandidate { + executable: discovered.expect("spawnable discovered executable"), + environment: selected_environment, + executes_project_code: false, + terminal, + })) + } + } + } + + fn resolve_candidate( + &self, + session: &BuildResolutionSession, + candidate: ExecutableCandidate, + ) -> Result, BuildResolutionError> { + let environment = session.environment.clone(); + let found = match candidate { + ExecutableCandidate::ProjectFile { path, requires, .. } => { + for required in requires { + if project_entry(session, &required, false)?.is_none() { + return Ok(None); + } + } + project_entry(session, &path, true)?.map(|executable| SelectedCandidate { + executable, + environment, + executes_project_code: true, + terminal: false, + }) + } + ExecutableCandidate::Environment { variable, path } => { + if !session + .plan + .environment_inputs + .iter() + .any(|input| input.eq_ignore_ascii_case(&variable)) + { + return Ok(None); + } + let Some(base) = environment_value(&environment, &variable) else { + return Ok(None); + }; + let base = PathBuf::from(base); + let relative = plan_relative_path(&path, "environment executable")?; + let Some(executable) = + discovery_path(&session.discovery_root, &base.join(relative)) + else { + return Ok(None); + }; + is_spawnable_windows_file(&executable).then_some(SelectedCandidate { + executable, + environment, + executes_project_code: false, + terminal: false, + }) + } + ExecutableCandidate::Command { command } => { + let path = environment_value(&environment, "PATH") + .and_then(|path| { + let directories: Vec<_> = std::env::split_paths(path) + .filter_map(|directory| { + discovery_path(&session.discovery_root, &directory) + }) + .collect(); + // An empty join would become an implicit cwd entry in + // find_system_tool instead of an empty search list. + (!directories.is_empty()).then(|| std::env::join_paths(directories)) + }) + .transpose() + .map_err(|error| { + BuildResolutionError::invalid_plan(format!( + "build tool search path is invalid: {error}" + )) + })?; + super::super::language_tools::find_system_tool(path.as_deref(), &command).map( + |executable| SelectedCandidate { + executable, + environment, + executes_project_code: false, + terminal: false, + }, + ) + } + ExecutableCandidate::Runtime { runtime } => session + .runtime_executables + .get(&runtime) + .cloned() + .map(|executable| SelectedCandidate { + executable, + environment, + executes_project_code: false, + terminal: false, + }), + ExecutableCandidate::Setting { setting_id, path } => { + let Some(path) = path else { return Ok(None) }; + let executable = PathBuf::from(&path); + if !executable.is_absolute() || !is_spawnable_windows_file(&executable) { + return Err(BuildResolutionError::configured(format!( + "configured build tool {setting_id} is not an executable file: {path}" + ))); + } + Some(SelectedCandidate { + executable, + environment, + executes_project_code: false, + terminal: true, + }) + } + }; + Ok(found) + } +} + +/// Mutable continuation owned by one upper-layer build operation. +#[derive(Debug)] +pub(crate) struct BuildResolutionSession { + plan: InvocationPlan, + workspace_root: PathBuf, + instance_root: PathBuf, + /// Frozen host cwd for environment/PATH/runtime discovery inputs. + discovery_root: PathBuf, + environment: BTreeMap, + runtime_executables: BTreeMap, + runtime_index: usize, + runtime_candidate_index: usize, + tool_candidate_index: usize, + pending: Option, + problems: Vec, + configured_setting: bool, +} + +/// The caller either executes one bounded version probe or receives the tool. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BuildResolutionStep { + Probe(VersionProbePlan), + Resolved(ResolvedBuildTool), +} + +/// A process plan only; creating this value grants no execution permission. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct VersionProbePlan { + pub(crate) executable: PathBuf, + pub(crate) arguments: Vec, + pub(crate) environment: BTreeMap, + pub(crate) runtime: Option, + pub(crate) minimum_version: String, + pub(crate) terminal: bool, +} + +impl VersionProbePlan { + fn new( + selection: SelectedCandidate, + arguments: Vec, + runtime: Option, + minimum_version: String, + ) -> Self { + Self { + executable: selection.executable, + arguments, + environment: selection.environment, + runtime, + minimum_version, + terminal: selection.terminal, + } + } + + /// Request body for Core's `build.checkToolVersion` after the caller has + /// captured combined stdout and stderr from this probe. + pub(crate) fn core_request(&self, output: impl Into) -> Value { + let mut request = json!({ "output": output.into() }); + if let Some(runtime) = &self.runtime { + request["runtime"] = json!(runtime); + } + request + } +} + +/// Core's version judgment, or a platform probe failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum VersionProbeOutcome { + Accepted { version: Option }, + Rejected { version: Option }, + Failed { message: String }, +} + +impl VersionProbeOutcome { + /// Parses the `version` value returned by `build.checkToolVersion`. + pub(crate) fn from_core(value: &Value) -> Result { + let value = value.get("version").unwrap_or(value); + let satisfied = value + .get("satisfied") + .and_then(Value::as_bool) + .ok_or_else(|| { + BuildResolutionError::invalid_plan( + "build.checkToolVersion returned no satisfied boolean", + ) + })?; + let version = value + .get("version") + .and_then(Value::as_str) + .map(str::to_string); + Ok(if satisfied { + Self::Accepted { version } + } else { + Self::Rejected { version } + }) + } +} + +/// Selected executable and the exact environment its probe must also use. +/// +/// `executes_project_code` remains an authorization input. Resolution alone +/// never grants workspace trust or permission to start the process. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ResolvedBuildTool { + pub(crate) executable: PathBuf, + pub(crate) environment: BTreeMap, + pub(crate) executes_project_code: bool, + pub(crate) version: Option, + pub(crate) runtime_executables: BTreeMap, +} + +/// Stable host-side category plus a diagnostic message for the upper layer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BuildResolutionError { + code: &'static str, + message: String, + terminal: bool, +} + +impl BuildResolutionError { + pub(crate) fn code(&self) -> &'static str { + self.code + } + + pub(crate) fn is_terminal(&self) -> bool { + self.terminal + } + + fn invalid_plan(message: impl Into) -> Self { + Self { + code: "plugin.build.invalidPlan", + message: message.into(), + terminal: true, + } + } + + fn project_escape(message: impl Into) -> Self { + Self { + code: "plugin.build.projectPathEscape", + message: message.into(), + terminal: true, + } + } + + fn configured(message: impl Into) -> Self { + Self { + code: "plugin.build.configuredToolUnavailable", + message: message.into(), + terminal: true, + } + } + + fn unavailable(message: String, terminal: bool, problems: &[String]) -> Self { + let message = if problems.is_empty() { + message + } else { + format!("{message}. {}", problems.join(". ")) + }; + Self { + code: if terminal { + "plugin.build.configuredToolUnavailable" + } else { + "plugin.build.toolUnavailable" + }, + message, + terminal, + } + } +} + +impl fmt::Display for BuildResolutionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for BuildResolutionError {} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct InvocationPlan { + #[serde(rename = "buildSystemID")] + build_system_id: String, + root: String, + executables: Vec, + #[serde(default)] + environment_inputs: Vec, + #[serde(default)] + runtime_dependencies: Vec, + version: Option, + minimum_version: Option, +} + +impl InvocationPlan { + fn probe(&self) -> Option<(Vec, String)> { + Some(( + self.version.as_ref()?.arguments.clone(), + self.minimum_version.clone()?, + )) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RuntimeDependencyPlan { + id: String, + display_name: Option, + host_managed: bool, + minimum_version: Option, + #[serde(default)] + executables: Vec, + version: Option, +} + +impl RuntimeDependencyPlan { + fn probe(&self) -> Option<(Vec, String)> { + Some(( + self.version.as_ref()?.arguments.clone(), + self.minimum_version.clone()?, + )) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct VersionProbe { + arguments: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +enum ExecutableCandidate { + ProjectFile { + path: String, + #[serde(default)] + requires: Vec, + #[serde(default, rename = "executesProjectCode")] + _executes_project_code: bool, + }, + Environment { + variable: String, + path: String, + }, + Command { + command: String, + }, + Runtime { + runtime: String, + }, + Setting { + #[serde(rename = "settingID")] + setting_id: String, + path: Option, + }, +} + +impl ExecutableCandidate { + fn has_configured_setting(&self) -> bool { + matches!(self, Self::Setting { path: Some(_), .. }) + } +} + +#[derive(Debug, Clone)] +enum ResolutionSource { + Candidate(ExecutableCandidate), + Direct { + executable: PathBuf, + environment: BTreeMap, + terminal: bool, + }, +} + +#[derive(Debug, Clone)] +struct SelectedCandidate { + executable: PathBuf, + environment: BTreeMap, + executes_project_code: bool, + terminal: bool, +} + +#[derive(Debug)] +struct PendingProbe { + target: PendingTarget, + selection: SelectedCandidate, + minimum_version: String, +} + +#[derive(Debug)] +enum PendingTarget { + Runtime { id: String }, + Tool, + ResolvedTool { version: Option }, +} + +fn absolute_existing_directory(path: &Path, label: &str) -> Result { + if !path.is_absolute() { + return Err(BuildResolutionError::invalid_plan(format!( + "{label} must be absolute: {}", + path.display() + ))); + } + let metadata = std::fs::symlink_metadata(path).map_err(|error| { + BuildResolutionError::invalid_plan(format!( + "{label} {} is unreadable: {error}", + path.display() + )) + })?; + if is_reparse_or_link(&metadata) || !metadata.is_dir() { + return Err(BuildResolutionError::project_escape(format!( + "{label} {} must be a real directory", + path.display() + ))); + } + path.canonicalize().map_err(|error| { + BuildResolutionError::invalid_plan(format!( + "{label} {} could not be resolved: {error}", + path.display() + )) + }) +} + +/// Freezes discovered paths before testing or returning them. Drive-relative +/// Windows paths are ambiguous even with a host cwd, so they are not candidates. +fn discovery_path(host_directory: &Path, path: &Path) -> Option { + if path.is_absolute() { + return Some(path.to_path_buf()); + } + if path + .components() + .any(|component| matches!(component, Component::Prefix(_) | Component::RootDir)) + { + return None; + } + Some(host_directory.join(path)) +} + +fn plan_relative_path(value: &str, label: &str) -> Result { + let path = Path::new(value); + if path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::Prefix(_) | Component::RootDir + ) + }) + { + return Err(BuildResolutionError::invalid_plan(format!( + "{label} must be workspace-relative: {value}" + ))); + } + Ok(value + .split(['/', '\\']) + .filter(|part| !part.is_empty() && *part != ".") + .fold(PathBuf::new(), |path, part| path.join(part))) +} + +fn project_entry( + session: &BuildResolutionSession, + relative: &str, + executable: bool, +) -> Result, BuildResolutionError> { + let relative = plan_relative_path(relative, "project executable")?; + let path = session.workspace_root.join(relative); + if !path.starts_with(&session.instance_root) { + return Err(BuildResolutionError::project_escape(format!( + "project executable {} is outside instance root {}", + path.display(), + session.instance_root.display() + ))); + } + if !reject_reparse_components(&session.workspace_root, &path)? { + return Ok(None); + } + let metadata = match std::fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(BuildResolutionError::invalid_plan(format!( + "project entry {} is unreadable: {error}", + path.display() + ))) + } + }; + if is_reparse_or_link(&metadata) || !metadata.is_file() { + return Ok(None); + } + let canonical = path.canonicalize().map_err(|error| { + BuildResolutionError::invalid_plan(format!( + "project entry {} could not be resolved: {error}", + path.display() + )) + })?; + if !path_is_within(&canonical, &session.instance_root) { + return Err(BuildResolutionError::project_escape(format!( + "project entry {} resolves outside instance root {}", + path.display(), + session.instance_root.display() + ))); + } + if executable && !has_windows_executable_type(&path) { + return Ok(None); + } + Ok(Some(path)) +} + +/// Returns `false` only when a path component does not exist. Other metadata +/// failures stay visible instead of being mistaken for a missing candidate. +fn reject_reparse_components(root: &Path, target: &Path) -> Result { + let relative = target.strip_prefix(root).map_err(|_| { + BuildResolutionError::project_escape(format!( + "{} is outside workspace {}", + target.display(), + root.display() + )) + })?; + let mut current = root.to_path_buf(); + for component in relative.components() { + current.push(component.as_os_str()); + let metadata = match std::fs::symlink_metadata(¤t) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(BuildResolutionError::invalid_plan(format!( + "project path {} is unreadable: {error}", + current.display() + ))) + } + }; + if is_reparse_or_link(&metadata) { + return Err(BuildResolutionError::project_escape(format!( + "project path {} crosses a reparse point", + current.display() + ))); + } + } + Ok(true) +} + +fn is_reparse_or_link(metadata: &std::fs::Metadata) -> bool { + #[cfg(windows)] + { + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + } + #[cfg(not(windows))] + { + metadata.file_type().is_symlink() + } +} + +fn path_is_within(path: &Path, root: &Path) -> bool { + #[cfg(windows)] + { + let path = path.components().collect::>(); + let root = root.components().collect::>(); + path.len() >= root.len() + && path.iter().zip(root.iter()).all(|(left, right)| { + left.as_os_str() + .to_string_lossy() + .eq_ignore_ascii_case(&right.as_os_str().to_string_lossy()) + }) + } + #[cfg(not(windows))] + { + path.starts_with(root) + } +} + +fn is_spawnable_windows_file(path: &Path) -> bool { + let Ok(metadata) = std::fs::symlink_metadata(path) else { + return false; + }; + !is_reparse_or_link(&metadata) && metadata.is_file() && has_windows_executable_type(path) +} + +fn has_windows_executable_type(path: &Path) -> bool { + path.extension() + .and_then(OsStr::to_str) + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("exe") + || extension.eq_ignore_ascii_case("cmd") + || extension.eq_ignore_ascii_case("bat") + }) +} + +fn environment_value<'a>(environment: &'a BTreeMap, key: &str) -> Option<&'a str> { + environment + .iter() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(key)) + .map(|(_, value)| value.as_str()) +} + +fn environment_set(environment: &mut BTreeMap, key: String, value: String) { + let aliases = environment + .keys() + .filter(|candidate| candidate.eq_ignore_ascii_case(&key)) + .cloned() + .collect::>(); + for alias in aliases { + environment.remove(&alias); + } + environment.insert(key, value); +} + +fn prepend_path( + environment: &mut BTreeMap, + executable: &Path, +) -> Result<(), BuildResolutionError> { + let directory = executable.parent().ok_or_else(|| { + BuildResolutionError::invalid_plan(format!( + "executable {} has no parent directory", + executable.display() + )) + })?; + let existing = environment_value(environment, "PATH").unwrap_or_default(); + let mut paths = vec![directory.to_path_buf()]; + paths.extend(std::env::split_paths(OsStr::new(existing))); + let joined = std::env::join_paths(paths).map_err(|error| { + BuildResolutionError::invalid_plan(format!("could not construct PATH: {error}")) + })?; + environment_set( + environment, + "PATH".into(), + joined.to_string_lossy().into_owned(), + ); + Ok(()) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build_resolver_tests.rs b/windows/tauri/src-tauri/src/plugin_host/build_resolver_tests.rs new file mode 100644 index 0000000000..273a58b6a1 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build_resolver_tests.rs @@ -0,0 +1,692 @@ +use super::build_resolver::{ + BuildResolutionStep, RuntimeExecutableInput, VersionProbeOutcome, VersionProbePlan, + WindowsBuildToolResolver, +}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1); + +struct Fixture { + root: PathBuf, +} + +impl Fixture { + fn new(label: &str) -> Self { + Self::under(std::env::temp_dir(), label) + } + + fn in_host_directory(label: &str) -> Self { + Self::under( + std::env::current_dir() + .expect("host directory") + .join(".artifacts"), + label, + ) + } + + fn under(parent: PathBuf, label: &str) -> Self { + let id = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed); + let root = parent.join(format!( + "lithe-build-resolver-{label}-{}-{id}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("fixture root"); + Self { root } + } + + fn directory(&self, relative: impl AsRef) -> PathBuf { + let path = self.root.join(relative); + std::fs::create_dir_all(&path).expect("fixture directory"); + path + } + + fn file(&self, relative: impl AsRef) -> PathBuf { + let path = self.root.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("fixture file parent"); + } + std::fs::write(&path, b"placeholder").expect("fixture file"); + path + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +fn contract_fixture(name: &str) -> Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../shared/contracts/plugins/v1/fixtures") + .join(name); + serde_json::from_str(&std::fs::read_to_string(path).expect("contract fixture")) + .expect("fixture JSON") +} + +fn invocation_plan(manifest: &Value, build_system_id: &str, root: &str, settings: Value) -> Value { + let result = super::scan::core_execute( + "build.invocationPlan", + json!({ + "manifest": manifest, + "buildSystemID": build_system_id, + "request": {"root": root, "platform": "windows", "settings": settings}, + }), + ) + .expect("Core invocation plan"); + assert_eq!(result["status"], "ok", "{result}"); + result["invocation"].clone() +} + +fn planned_fixture_case(name: &str) -> (Value, String, Value) { + let cases = contract_fixture("build-plans.json"); + let case = cases["cases"] + .as_array() + .expect("cases") + .iter() + .find(|case| case["name"] == name) + .unwrap_or_else(|| panic!("missing build plan case {name}")); + let manifest = contract_fixture(case["plugin"].as_str().expect("plugin fixture")); + let build_system = case["buildSystemID"] + .as_str() + .expect("build system") + .to_string(); + let mut request = case["request"].clone(); + request["platform"] = json!("windows"); + let result = super::scan::core_execute( + "build.invocationPlan", + json!({ + "manifest": manifest, + "buildSystemID": build_system, + "request": request, + }), + ) + .expect("Core fixture plan"); + assert_eq!(result["status"], "ok", "{result}"); + (manifest, build_system, result["invocation"].clone()) +} + +fn check_version( + manifest: &Value, + build_system: &str, + probe: &VersionProbePlan, + output: &str, +) -> VersionProbeOutcome { + let result = super::scan::core_execute( + "build.checkToolVersion", + json!({ + "manifest": manifest, + "buildSystemID": build_system, + "request": probe.core_request(output), + }), + ) + .expect("Core version check"); + assert_eq!(result["status"], "ok", "{result}"); + VersionProbeOutcome::from_core(&result).expect("version outcome") +} + +fn path_environment(path: &Path) -> BTreeMap { + BTreeMap::from([( + "PATH".to_string(), + std::env::join_paths([path]) + .expect("fixture PATH") + .to_string_lossy() + .into_owned(), + )]) +} + +fn first_path(environment: &BTreeMap) -> PathBuf { + let value = environment + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("PATH")) + .expect("PATH") + .1; + std::env::split_paths(value) + .next() + .expect("first PATH entry") +} + +fn expect_resolved(step: BuildResolutionStep) -> super::build_resolver::ResolvedBuildTool { + match step { + BuildResolutionStep::Resolved(tool) => tool, + BuildResolutionStep::Probe(probe) => { + panic!("unexpected probe for {}", probe.executable.display()) + } + } +} + +fn expect_probe(step: BuildResolutionStep) -> VersionProbePlan { + match step { + BuildResolutionStep::Probe(probe) => probe, + BuildResolutionStep::Resolved(tool) => { + panic!("unexpected resolved tool {}", tool.executable.display()) + } + } +} + +#[test] +fn configured_tool_with_spaces_wins_and_owns_its_path_environment() { + let fixture = Fixture::new("configured-spaces"); + let configured = fixture.file("custom tools/fixture-build.exe"); + let fallback = fixture.directory("fallback"); + fixture.file("fallback/fixture-build.cmd"); + let manifest = contract_fixture("build-tool-path.plugin.json"); + let plan = invocation_plan( + &manifest, + "fixture", + ".", + json!({"toolPath": configured.to_string_lossy()}), + ); + let resolver = + WindowsBuildToolResolver::with_environment(path_environment(&fallback), BTreeMap::new()); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let resolved = expect_resolved(resolver.advance(&mut session, None).expect("resolved")); + + assert_eq!(resolved.executable, configured); + assert_eq!( + first_path(&resolved.environment), + configured.parent().unwrap() + ); + assert!(!resolved.executes_project_code); +} + +#[test] +fn configured_missing_or_directory_is_terminal_and_never_falls_back() { + for failure in ["missing", "directory"] { + let fixture = Fixture::new(failure); + let configured = fixture.root.join("configured tool.exe"); + if failure == "directory" { + std::fs::create_dir_all(&configured).expect("configured directory"); + } + let fallback = fixture.directory("fallback"); + fixture.file("fallback/fixture-build.exe"); + let manifest = contract_fixture("build-tool-path.plugin.json"); + let plan = invocation_plan( + &manifest, + "fixture", + ".", + json!({"toolPath": configured.to_string_lossy()}), + ); + let resolver = WindowsBuildToolResolver::with_environment( + path_environment(&fallback), + BTreeMap::new(), + ); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let error = resolver + .advance(&mut session, None) + .expect_err("configured failure must terminate"); + assert_eq!(error.code(), "plugin.build.configuredToolUnavailable"); + assert!(error.is_terminal()); + assert!(error.to_string().contains("toolPath")); + } +} + +#[test] +fn configured_tool_version_rejection_and_probe_failure_are_terminal() { + let fixture = Fixture::new("configured-version"); + let configured = fixture.file("configured/build.exe"); + let path = fixture.directory("path"); + fixture.file("path/fixture-build.exe"); + let mut manifest = contract_fixture("build-tool-path.plugin.json"); + manifest["buildSystems"][0]["invocation"]["minimumVersion"] = json!("2"); + manifest["buildSystems"][0]["invocation"]["version"] = json!({ + "arguments":["--version"], "pattern":"build (\\d+(?:\\.\\d+)*)" + }); + let plan = invocation_plan( + &manifest, + "fixture", + ".", + json!({"toolPath": configured.to_string_lossy()}), + ); + let resolver = + WindowsBuildToolResolver::with_environment(path_environment(&path), BTreeMap::new()); + + for failed in [false, true] { + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let probe = expect_probe( + resolver + .advance(&mut session, None) + .expect("configured probe"), + ); + assert_eq!(probe.executable, configured); + assert!(probe.terminal); + assert_eq!(first_path(&probe.environment), configured.parent().unwrap()); + let outcome = if failed { + VersionProbeOutcome::Failed { + message: "controlled probe failure".into(), + } + } else { + check_version(&manifest, "fixture", &probe, "build 1.0") + }; + let error = resolver + .advance(&mut session, Some(outcome)) + .expect_err("configured version failure must not discover another tool"); + assert_eq!(error.code(), "plugin.build.configuredToolUnavailable"); + assert!(error.is_terminal()); + } +} + +#[test] +fn unset_empty_and_reset_settings_all_resume_core_ordered_discovery() { + let fixture = Fixture::new("empty-setting"); + let path = fixture.directory("path"); + let fallback = fixture.file("path/fixture-build.bat"); + let manifest = contract_fixture("build-tool-path.plugin.json"); + let resolver = + WindowsBuildToolResolver::with_environment(path_environment(&path), BTreeMap::new()); + + for settings in [json!({}), json!({"toolPath": ""}), json!({})] { + let plan = invocation_plan(&manifest, "fixture", ".", settings); + assert_eq!(plan["executables"][0]["path"], Value::Null); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let resolved = expect_resolved(resolver.advance(&mut session, None).expect("resolved")); + assert_eq!(resolved.executable, fallback); + } +} + +#[test] +fn published_build_v2_executable_setting_uses_the_same_terminal_candidate_path() { + let fixture = Fixture::new("legacy-setting"); + let configured = fixture.file("legacy tools/build.exe"); + let fallback = fixture.directory("path"); + fixture.file("path/fixture-build.exe"); + let mut manifest = contract_fixture("build-tool-path.plugin.json"); + manifest["requires"] + .as_object_mut() + .expect("requires") + .remove("executableResolution"); + manifest["requires"]["buildSystems"] = json!(2); + manifest["buildSystems"][0]["invocation"]["executables"] + .as_array_mut() + .expect("executables") + .remove(0); + manifest["buildSystems"][0]["invocation"]["executableSetting"] = json!("toolPath"); + + let plan = invocation_plan( + &manifest, + "fixture", + ".", + json!({"toolPath": configured.to_string_lossy()}), + ); + assert_eq!(plan["executables"][0]["kind"], "setting"); + let resolver = + WindowsBuildToolResolver::with_environment(path_environment(&fallback), BTreeMap::new()); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let resolved = expect_resolved(resolver.advance(&mut session, None).expect("resolved")); + assert_eq!(resolved.executable, configured); +} + +#[test] +fn build_plans_gradle_order_selects_the_project_wrapper_without_probing_it() { + let fixture = Fixture::new("gradle-wrapper"); + let wrapper = fixture.file("gradlew.bat"); + fixture.file("gradle/wrapper/gradle-wrapper.properties"); + let java = fixture.file("jdk/bin/java.exe"); + let gradle_home = fixture.directory("gradle-home"); + fixture.file("gradle-home/bin/gradle.bat"); + let path = fixture.directory("path"); + fixture.file("path/gradle.exe"); + let (manifest, build_system, plan) = + planned_fixture_case("gradle prefers the batch wrapper on windows"); + let mut environment = path_environment(&path); + environment.insert( + "GRADLE_HOME".into(), + gradle_home.to_string_lossy().into_owned(), + ); + let resolver = WindowsBuildToolResolver::with_environment( + environment, + BTreeMap::from([( + "java".into(), + RuntimeExecutableInput::discovered(java.clone()), + )]), + ); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let probe = expect_probe(resolver.advance(&mut session, None).expect("runtime probe")); + assert_eq!(probe.executable, java); + assert_eq!(probe.arguments, ["-version"]); + let outcome = check_version(&manifest, &build_system, &probe, "java version \"17.0.9\""); + let resolved = expect_resolved( + resolver + .advance(&mut session, Some(outcome)) + .expect("wrapper resolution"), + ); + assert_eq!( + resolved.executable, + wrapper.canonicalize().expect("wrapper path") + ); + assert!(resolved.executes_project_code); + assert_eq!(resolved.runtime_executables["java"], java); +} + +#[test] +fn path_lookup_uses_windows_launcher_extensions_and_ignores_extensionless_files() { + let fixture = Fixture::new("pathext"); + fixture.directory("crates/app"); + let path = fixture.directory("path"); + fixture.file("path/cargo"); + let command = fixture.file("path/cargo.cmd"); + let (manifest, build_system, plan) = + planned_fixture_case("cargo invocation is the cargo command"); + let resolver = + WindowsBuildToolResolver::with_environment(path_environment(&path), BTreeMap::new()); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let probe = expect_probe(resolver.advance(&mut session, None).expect("probe")); + assert_eq!(probe.executable, command); + let outcome = check_version(&manifest, &build_system, &probe, "cargo 1.80.1"); + let resolved = expect_resolved( + resolver + .advance(&mut session, Some(outcome)) + .expect("resolved"), + ); + assert_eq!(resolved.version.as_deref(), Some("1.80.1")); +} + +#[test] +fn environment_candidates_read_only_declared_inputs() { + let fixture = Fixture::new("environment-whitelist"); + let environment_home = fixture.directory("environment-home"); + fixture.file("environment-home/bin/build.exe"); + let fallback_path = fixture.directory("path"); + let fallback = fixture.file("path/fallback.exe"); + let plan = json!({ + "buildSystemID": "fixture", + "root": ".", + "executables": [ + {"kind":"environment", "variable":"SECRET_HOME", "path":"bin/build.exe"}, + {"kind":"command", "command":"fallback"} + ], + "environmentInputs": [], + "runtimeDependencies": [], + "version": null, + "minimumVersion": null + }); + let mut environment = path_environment(&fallback_path); + environment.insert( + "SECRET_HOME".into(), + environment_home.to_string_lossy().into_owned(), + ); + let resolver = WindowsBuildToolResolver::with_environment(environment, BTreeMap::new()); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let resolved = expect_resolved(resolver.advance(&mut session, None).expect("resolved")); + assert_eq!(resolved.executable, fallback); +} + +fn assert_relative_discovery_is_frozen(kind: &str) { + // All paths really exist relative to the host cwd, while execution will + // use a different workspace cwd. No test mutates the process-wide cwd. + let host_directory = std::env::current_dir().expect("host directory"); + let fixture = Fixture::in_host_directory(kind); + let executable = fixture.file("tools/bin/build.exe"); + let relative_home = fixture + .root + .join("tools") + .strip_prefix(&host_directory) + .expect("fixture belongs to host directory") + .to_path_buf(); + let relative_executable = relative_home.join("bin/build.exe"); + let mut environment = path_environment(&relative_home.join("bin")); + environment.insert( + "CUSTOM_HOME".into(), + relative_home.to_string_lossy().into_owned(), + ); + let candidate = match kind { + "environment" => { + json!({"kind":"environment", "variable":"CUSTOM_HOME", "path":"bin/build.exe"}) + } + "path" => json!({"kind":"command", "command":"build"}), + "host-runtime" => json!({"kind":"runtime", "runtime":"custom"}), + _ => panic!("unexpected relative discovery kind {kind}"), + }; + let plan = json!({ + "buildSystemID":"fixture", "root":".", "executables":[candidate], + "environmentInputs":["CUSTOM_HOME"], + "runtimeDependencies": if kind == "host-runtime" { json!([{ + "id":"custom", "hostManaged":true, "executables":[] + }]) } else { json!([]) }, + "version":{"arguments":["--version"]}, "minimumVersion":"1" + }); + let resolver = WindowsBuildToolResolver::with_environment( + environment, + BTreeMap::from([( + "custom".into(), + RuntimeExecutableInput::discovered(relative_executable), + )]), + ); + let workspace = fixture.directory("workspace"); + let mut session = resolver.start(&plan, &workspace).expect("session"); + let probe = expect_probe(resolver.advance(&mut session, None).expect("probe")); + assert!( + probe.executable.is_absolute(), + "probe must not change identity with cwd" + ); + assert_eq!(probe.executable, executable); + let resolved = expect_resolved( + resolver + .advance( + &mut session, + Some(VersionProbeOutcome::Accepted { + version: Some("1".into()), + }), + ) + .expect("resolved"), + ); + assert_eq!(resolved.executable, executable); + if kind == "host-runtime" { + assert_eq!(resolved.runtime_executables["custom"], executable); + assert_eq!( + first_path(&resolved.environment), + executable.parent().expect("tool directory") + ); + } +} + +#[test] +fn relative_environment_home_is_frozen_before_the_probe() { + assert_relative_discovery_is_frozen("environment"); +} + +#[test] +fn relative_path_entry_is_frozen_before_the_probe() { + assert_relative_discovery_is_frozen("path"); +} + +#[test] +fn relative_host_runtime_is_frozen_before_the_probe() { + assert_relative_discovery_is_frozen("host-runtime"); +} + +#[test] +fn rejected_runtime_candidate_does_not_leak_its_path_into_the_next_candidate() { + let fixture = Fixture::new("runtime-isolation"); + let old_home = fixture.directory("old-runtime"); + let old = fixture.file("old-runtime/old.exe"); + let path = fixture.directory("path"); + let fallback = fixture.file("path/custom.exe"); + let mut manifest = contract_fixture("build-tool-path.plugin.json"); + manifest["buildSystems"][0]["invocation"] = json!({ + "executables": [{"kind":"runtime", "runtime":"custom"}], + "environmentInputs": ["CUSTOM_HOME"], + "runtimeDependencies": [{ + "id":"custom", + "minimumVersion":"2", + "executables":[ + {"kind":"environment", "variable":"CUSTOM_HOME", "path":"old.exe"}, + {"kind":"command", "command":"custom"} + ], + "version":{"arguments":["--version"], "pattern":"custom (\\d+(?:\\.\\d+)*)"} + }] + }); + manifest["requires"] + .as_object_mut() + .expect("requires") + .remove("executableResolution"); + let plan = invocation_plan(&manifest, "fixture", ".", json!({})); + let mut environment = path_environment(&path); + environment.insert( + "CUSTOM_HOME".into(), + old_home.to_string_lossy().into_owned(), + ); + let resolver = WindowsBuildToolResolver::with_environment(environment, BTreeMap::new()); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + + let old_probe = expect_probe(resolver.advance(&mut session, None).expect("old probe")); + assert_eq!(old_probe.executable, old); + assert_eq!(first_path(&old_probe.environment), old_home); + let rejected = check_version(&manifest, "fixture", &old_probe, "custom 1.0"); + let fallback_probe = expect_probe( + resolver + .advance(&mut session, Some(rejected)) + .expect("fallback probe"), + ); + assert_eq!(fallback_probe.executable, fallback); + assert_eq!(first_path(&fallback_probe.environment), path); + assert!( + !std::env::split_paths(fallback_probe.environment.get("PATH").expect("PATH")) + .any(|entry| entry == old_home) + ); + + let accepted = check_version(&manifest, "fixture", &fallback_probe, "custom 2.3"); + let resolved = expect_resolved( + resolver + .advance(&mut session, Some(accepted)) + .expect("resolved runtime tool"), + ); + assert_eq!(resolved.executable, fallback); + assert_eq!(resolved.runtime_executables["custom"], fallback); +} + +#[test] +fn host_runtime_probe_failure_is_isolated_and_explicit_selection_is_terminal() { + let fixture = Fixture::new("host-runtime-failure"); + let selected = fixture.file("selected/bin/java.exe"); + let path = fixture.directory("path"); + let fallback = fixture.file("path/java.exe"); + let (_, _, plan) = planned_fixture_case("gradle prefers the batch wrapper on windows"); + + for explicit in [false, true] { + let resolver = WindowsBuildToolResolver::with_environment( + path_environment(&path), + BTreeMap::from([( + "java".into(), + RuntimeExecutableInput { + executable: selected.clone(), + environment: BTreeMap::from([("JAVA_HOME".into(), "selected-home".into())]), + explicit, + }, + )]), + ); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let selected_probe = expect_probe(resolver.advance(&mut session, None).expect("probe")); + assert_eq!(selected_probe.executable, selected); + assert_eq!(selected_probe.environment["JAVA_HOME"], "selected-home"); + let next = resolver.advance( + &mut session, + Some(VersionProbeOutcome::Failed { + message: "controlled runtime failure".into(), + }), + ); + if explicit { + let error = next.expect_err("explicit runtime must not fall back"); + assert_eq!(error.code(), "plugin.build.configuredToolUnavailable"); + assert!(error.is_terminal()); + } else { + let probe = expect_probe(next.expect("discovered runtime fallback")); + assert_eq!(probe.executable, fallback); + assert_eq!(first_path(&probe.environment), path); + assert!(!probe.environment.contains_key("JAVA_HOME")); + } + } +} + +#[test] +fn project_candidates_are_scoped_to_the_selected_instance_root() { + let fixture = Fixture::new("instance-roots"); + let one = fixture.file("one/wrapper.cmd"); + let two = fixture.file("two/wrapper.cmd"); + let mut manifest = contract_fixture("build-tool-path.plugin.json"); + manifest["buildSystems"][0]["invocation"] = json!({ + "executables": [{"kind":"projectFile", "path":"wrapper.cmd"}] + }); + manifest["requires"] + .as_object_mut() + .expect("requires") + .remove("executableResolution"); + let resolver = WindowsBuildToolResolver::with_environment(BTreeMap::new(), BTreeMap::new()); + for (root, expected) in [("one", one), ("two", two)] { + let plan = invocation_plan(&manifest, "fixture", root, json!({})); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let resolved = expect_resolved(resolver.advance(&mut session, None).expect("resolved")); + assert_eq!( + resolved.executable, + expected.canonicalize().expect("project executable") + ); + assert!(resolved.executes_project_code); + } + + let mut escaped = invocation_plan(&manifest, "fixture", "one", json!({})); + escaped["executables"][0]["path"] = json!("two/wrapper.cmd"); + let mut session = resolver.start(&escaped, &fixture.root).expect("session"); + let error = resolver + .advance(&mut session, None) + .expect_err("sibling instance path must fail"); + assert_eq!(error.code(), "plugin.build.projectPathEscape"); +} + +#[test] +fn project_candidate_rejects_a_junction_or_symlink_escape() { + let fixture = Fixture::new("project link"); + fixture.directory("instance"); + let outside = fixture.directory("outside"); + fixture.file("outside/wrapper.cmd"); + let link = fixture.root.join("instance").join("linked"); + create_directory_link(&outside, &link); + let mut manifest = contract_fixture("build-tool-path.plugin.json"); + manifest["buildSystems"][0]["invocation"] = json!({ + "executables": [{"kind":"projectFile", "path":"linked/wrapper.cmd"}] + }); + manifest["requires"] + .as_object_mut() + .expect("requires") + .remove("executableResolution"); + let plan = invocation_plan(&manifest, "fixture", "instance", json!({})); + let resolver = WindowsBuildToolResolver::with_environment(BTreeMap::new(), BTreeMap::new()); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let error = resolver + .advance(&mut session, None) + .expect_err("link escape must fail"); + assert_eq!(error.code(), "plugin.build.projectPathEscape"); +} + +fn create_directory_link(target: &Path, link: &Path) { + #[cfg(windows)] + { + // Junctions exercise the reparse boundary without Developer Mode or + // admin privilege. Native separators keep mklink from reading a slash + // in a fixture path as a command option; Command owns argument quoting. + let target = target.components().collect::(); + let link = link.components().collect::(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let mut command = std::process::Command::new("cmd"); + command + .args(["/D", "/C", "mklink", "/J"]) + .arg(&link) + .arg(&target); + let outcome = lithe_git_host::run( + &mut command, + None, + || std::time::Instant::now() >= deadline, + || {}, + |_, _| {}, + ); + assert!( + outcome.failure.is_none() && outcome.status.is_some_and(|status| status.success()), + "junction creation failed: {}", + String::from_utf8_lossy(&outcome.stderr) + ); + } + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).expect("directory symlink"); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build_tests.rs b/windows/tauri/src-tauri/src/plugin_host/build_tests.rs new file mode 100644 index 0000000000..85332908d0 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build_tests.rs @@ -0,0 +1,1636 @@ +//! Tests for the M4-A build registry: detection, static models, option +//! persistence, and sync fingerprints against the real Core, plus the +//! failure and staleness paths. No test starts a process: this batch is +//! read-only, so every scenario is filesystem- and envelope-level only. + +use super::build_registry::{ + BuildCoreExecutor, BuildOptionsPersistence, BuildPackageInput, BuildPackageSource, + BuildRegistry, StoredBuildOptions, +}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, +}; + +const FIXTURES_DIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/contracts/plugins/v1/fixtures" +); + +fn static_fixture() -> Value { + let text = + std::fs::read_to_string(Path::new(FIXTURES_DIR).join("build-static-file.plugin.json")) + .expect("static fixture exists"); + serde_json::from_str(&text).expect("static fixture is JSON") +} + +/// Deterministic temp workspace that removes itself on drop; the optional +/// space in the name also exercises paths with spaces through Core. +struct TempWorkspace { + root: PathBuf, +} + +impl TempWorkspace { + fn new(name: &str, spaced: bool) -> Self { + let mut root = std::env::temp_dir(); + root.push(format!("lithe-build-test-{}-{}", name, std::process::id())); + if spaced { + root.push("dir with spaces"); + } + std::fs::create_dir_all(&root).expect("temp workspace created"); + TempWorkspace { root } + } + + fn root(&self) -> String { + self.root.to_string_lossy().to_string() + } + + fn write(&self, relative: &str, contents: &str) { + let path = self.root.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("parent created"); + } + std::fs::write(path, contents).expect("file written"); + } +} + +impl Drop for TempWorkspace { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +/// Option store rooted in its own temp directory. +struct TempOptions { + root: PathBuf, +} + +impl TempOptions { + fn new(name: &str) -> Self { + let mut root = std::env::temp_dir(); + root.push(format!( + "lithe-build-options-{}-{}", + name, + std::process::id() + )); + std::fs::create_dir_all(&root).expect("temp options dir created"); + TempOptions { root } + } + + fn stored(&self, workspace_root: &str) -> StoredBuildOptions { + self.load(workspace_root).expect("options load") + } +} + +impl Drop for TempOptions { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +impl BuildOptionsPersistence for TempOptions { + fn load(&self, workspace_root: &str) -> Result { + let path = self.root.join(format!( + "{}.json", + super::store::workspace_state_key(workspace_root) + )); + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(StoredBuildOptions::default()) + } + Err(error) => return Err(error.to_string()), + }; + serde_json::from_str(&text).map_err(|error| error.to_string()) + } + + fn save(&self, workspace_root: &str, options: &StoredBuildOptions) -> Result<(), String> { + let path = self.root.join(format!( + "{}.json", + super::store::workspace_state_key(workspace_root) + )); + std::fs::write( + &path, + serde_json::to_string_pretty(options).map_err(|e| e.to_string())?, + ) + .map_err(|error| error.to_string()) + } +} + +/// One fixture package whose enabled state and version the test controls, +/// simulating plugin management without touching shared state. +struct FixedPackages { + plugin_id: String, + manifest: Mutex, + version: Mutex, + enabled: Mutex, +} + +impl FixedPackages { + fn one(manifest: Value) -> Self { + FixedPackages { + plugin_id: manifest["id"].as_str().expect("fixture id").to_string(), + manifest: Mutex::new(manifest), + version: Mutex::new("0.1.0".to_string()), + enabled: Mutex::new(true), + } + } + + fn set_enabled(&self, enabled: bool) { + *self.enabled.lock().expect("enabled flag") = enabled; + } + + fn set_version(&self, version: &str) { + *self.version.lock().expect("version") = version.to_string(); + } + + fn set_manifest(&self, manifest: Value) { + *self.manifest.lock().expect("manifest") = manifest; + } + + fn plugin_id(&self) -> &str { + &self.plugin_id + } + + fn input(&self) -> Option { + if !*self.enabled.lock().expect("enabled flag") { + return None; + } + let version = self.version.lock().expect("version").clone(); + Some(BuildPackageInput { + plugin_id: self.plugin_id.clone(), + version: version.clone(), + manifest: self.manifest.lock().expect("manifest").clone(), + content_identity: version, + }) + } +} + +impl BuildPackageSource for FixedPackages { + fn accepted_packages(&self) -> Vec { + self.input().into_iter().collect() + } + + fn package(&self, plugin_id: &str) -> Option { + if plugin_id == self.plugin_id { + self.input() + } else { + None + } + } +} + +/// Real Core through the shared host envelope. +struct RealCore; + +impl BuildCoreExecutor for RealCore { + fn execute(&self, command: &str, payload: Value) -> Result { + super::scan::core_execute(command, payload) + } +} + +/// Core double with scripted responses, for envelope failure paths. +struct ScriptedCore { + responses: Mutex>>, +} + +impl ScriptedCore { + fn rejected(code: &str, message: &str) -> Self { + ScriptedCore { + responses: Mutex::new(vec![Ok(json!({ + "status": "rejected", + "error": { "code": code, "message": message }, + }))]), + } + } + + fn broken() -> Self { + ScriptedCore { + responses: Mutex::new(vec![Ok(json!({ "status": "mysterious" }))]), + } + } +} + +impl BuildCoreExecutor for ScriptedCore { + fn execute(&self, _command: &str, _payload: Value) -> Result { + self.responses + .lock() + .expect("scripted responses") + .pop() + .expect("scripted response for every call") + } +} + +fn cargo_toml(features: &[(&str, &[&str])]) -> String { + let mut toml = String::from( + "[package]\nname = \"fixture-app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[[bin]]\nname = \"fixture-app\"\npath = \"src/main.rs\"\n", + ); + if !features.is_empty() { + toml.push_str("\n[features]\n"); + for (name, deps) in features { + let values = deps + .iter() + .map(|dep| format!("\"{dep}\"")) + .collect::>() + .join(", "); + toml.push_str(&format!("{name} = [{values}]\n")); + } + } + toml +} + +fn standard_workspace(name: &str) -> TempWorkspace { + let workspace = TempWorkspace::new(name, true); + workspace.write("Cargo.toml", &cargo_toml(&[])); + workspace.write("src/main.rs", "fn main() {}\n"); + workspace +} + +fn view_json(view: &super::build_registry::BuildView) -> Value { + serde_json::to_value(view).expect("view serializes") +} + +fn option_value<'a>(view: &'a Value, root: &str, option_id: &str) -> &'a Value { + view["instances"] + .as_array() + .expect("instances") + .iter() + .find(|instance| instance["root"].as_str() == Some(root)) + .unwrap_or_else(|| panic!("instance at {root}")) + .get("options") + .expect("options list") + .as_array() + .expect("options array") + .iter() + .find(|option| option["id"].as_str() == Some(option_id)) + .unwrap_or_else(|| panic!("option {option_id}")) + .get("value") + .expect("option value") +} + +#[test] +fn detect_reads_the_static_model_and_describes_options() { + let workspace = standard_workspace("detect"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("detect"); + let mut registry = BuildRegistry::new(); + + let view = registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect succeeds"); + let view = view_json(&view); + + assert_eq!(view["instances"].as_array().expect("instances").len(), 1); + let instance = &view["instances"][0]; + assert_eq!(instance["buildSystemID"], json!("cargo")); + assert_eq!(instance["modelSource"], json!("static")); + assert_eq!( + instance["pluginID"], + json!("dev.lithe.fixture.build-static-file") + ); + assert_eq!( + instance["model"]["projects"][0]["name"], + json!("fixture-app") + ); + assert_eq!(instance["stale"], json!(false)); + let kinds: Vec<&str> = instance["options"] + .as_array() + .expect("options") + .iter() + .filter_map(|option| option["kind"].as_str()) + .collect(); + assert!(kinds.contains(&"boolean") && kinds.contains(&"multiSelect")); + assert!(instance["optionFingerprint"].is_string()); +} + +#[test] +fn project_settings_reload_invalidates_only_its_original_window_and_retries_the_same_owner() { + let workspace = standard_workspace("settings-reload-scope"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("settings-reload-scope"); + let mut registry = BuildRegistry::new(); + let first = registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .unwrap(); + let other = registry + .refresh( + &RealCore, + &packages, + &options, + "workspace-other", + &workspace.root(), + ) + .unwrap(); + let old = registry + .model_context( + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + ) + .unwrap(); + let untouched = registry + .model_context( + "workspace-other", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + ) + .unwrap(); + let generation = registry + .begin_settings_reload("main", &workspace.root(), packages.plugin_id()) + .unwrap(); + assert!(!registry.context_is_current(&old)); + assert!(registry.context_is_current(&untouched)); + assert!(registry + .model_context( + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + "." + ) + .is_err()); + // A failed drain performs no publication or release. Retrying does not + // advance another workspace's generation or borrow its process owner. + assert_eq!( + registry + .begin_settings_reload("main", &workspace.root(), packages.plugin_id()) + .unwrap(), + generation + ); + let result = registry + .reload_settings( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + generation, + packages.plugin_id(), + &|| Ok(()), + ) + .unwrap(); + assert_eq!(result.generation, first.generation); + assert!(!registry.context_is_current(&old)); + assert_eq!( + view_json(®istry.view("workspace-other")), + view_json(&other) + ); + assert!(registry.context_is_current(&untouched)); + assert!(registry + .model_context( + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + "." + ) + .is_ok()); +} + +#[test] +fn settings_reload_function_revision_survives_completion_and_is_scoped_to_one_plugin_window() { + let workspace = standard_workspace("settings-reload-function-scope"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("settings-reload-function-scope"); + let mut registry = BuildRegistry::new(); + assert_eq!( + registry + .settings_scope_revision("main", &workspace.root(), packages.plugin_id()) + .unwrap(), + 0 + ); + for label in ["main", "workspace-other"] { + registry + .refresh(&RealCore, &packages, &options, label, &workspace.root()) + .unwrap(); + } + let revision = |registry: &BuildRegistry, label, plugin| { + registry.function_scope_revision(label, &workspace.root(), plugin) + }; + let old = revision(®istry, "main", packages.plugin_id()).unwrap(); + let other_window = revision(®istry, "workspace-other", packages.plugin_id()).unwrap(); + let other_plugin = revision(®istry, "main", "other.plugin").unwrap(); + let generation = registry + .begin_settings_reload("main", &workspace.root(), packages.plugin_id()) + .unwrap(); + assert!(revision(®istry, "main", packages.plugin_id()).is_err()); + assert_eq!( + revision(®istry, "workspace-other", packages.plugin_id()).unwrap(), + other_window + ); + assert_eq!( + revision(®istry, "main", "other.plugin").unwrap(), + other_plugin + ); + registry + .begin_settings_reload("main", &workspace.root(), packages.plugin_id()) + .unwrap(); + registry.finish_settings_reload("main", generation, packages.plugin_id()); + assert_eq!( + revision(®istry, "main", packages.plugin_id()).unwrap(), + old + 1 + ); + assert_eq!( + revision(®istry, "main", "other.plugin").unwrap(), + other_plugin + ); +} + +#[test] +fn project_settings_reload_refuses_late_close_reopen_and_failed_receipt_check() { + let workspace = standard_workspace("settings-reload-stale"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("settings-reload-stale"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .unwrap(); + let generation = registry + .begin_settings_reload("main", &workspace.root(), packages.plugin_id()) + .unwrap(); + let before = view_json(®istry.view("main")); + let failed = registry.reload_settings( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + generation, + packages.plugin_id(), + &|| Err("plugin.settings.conflict".into()), + ); + assert_eq!(failed.unwrap_err(), "plugin.settings.conflict"); + assert_eq!(view_json(®istry.view("main")), before); + registry.close_workspace("main", &workspace.root()); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .unwrap(); + let reopened = view_json(®istry.view("main")); + assert!(registry + .reload_settings( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + generation, + packages.plugin_id(), + &|| Ok(()) + ) + .is_err()); + registry.finish_settings_reload("main", generation, packages.plugin_id()); + assert_eq!(view_json(®istry.view("main")), reopened); +} + +#[test] +fn project_settings_reload_preserves_core_ownership_and_unrelated_model_contexts() { + struct Packages(Vec); + impl BuildPackageSource for Packages { + fn accepted_packages(&self) -> Vec { + self.0.clone() + } + fn package(&self, id: &str) -> Option { + self.0 + .iter() + .find(|package| package.plugin_id == id) + .cloned() + } + } + let workspace = standard_workspace("settings-reload-ownership"); + let options = TempOptions::new("settings-reload-ownership"); + let mut winner = FixedPackages::one(static_fixture()).input().unwrap(); + winner.manifest["buildSystems"][0]["detection"]["priority"] = json!(1); + let mut loser = winner.clone(); + loser.plugin_id = "io.example.shadowed".into(); + loser.manifest["id"] = json!(loser.plugin_id); + loser.manifest["buildSystems"][0]["detection"]["priority"] = json!(0); + let packages = Packages(vec![winner.clone(), loser.clone()]); + let mut registry = BuildRegistry::new(); + let before = registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .unwrap(); + let context = registry + .model_context("main", &workspace.root(), &winner.plugin_id, "cargo", ".") + .unwrap(); + let generation = registry + .begin_settings_reload("main", &workspace.root(), &loser.plugin_id) + .unwrap(); + let after = registry + .reload_settings( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + generation, + &loser.plugin_id, + &|| Ok(()), + ) + .unwrap(); + assert!(registry.context_is_current(&context)); + assert_eq!(view_json(&before), view_json(&after)); + assert!(after + .instances + .iter() + .all(|instance| instance["pluginID"] != loser.plugin_id)); +} + +#[test] +fn rejected_manifest_uses_its_original_request_index_for_the_plugin_id() { + struct MixedPackages(Vec); + impl BuildPackageSource for MixedPackages { + fn accepted_packages(&self) -> Vec { + self.0.clone() + } + + fn package(&self, plugin_id: &str) -> Option { + self.0 + .iter() + .find(|package| package.plugin_id == plugin_id) + .cloned() + } + } + + let workspace = standard_workspace("rejection-owner"); + let options = TempOptions::new("rejection-owner"); + let valid = FixedPackages::one(static_fixture()) + .input() + .expect("valid package"); + let mut invalid = valid.clone(); + invalid.plugin_id = "io.example.invalid-build".into(); + invalid.manifest["id"] = json!(invalid.plugin_id); + invalid.manifest["version"] = json!("not-a-version"); + let packages = MixedPackages(vec![valid.clone(), invalid.clone()]); + let mut registry = BuildRegistry::new(); + let view = view_json( + ®istry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("the valid package still detects"), + ); + + assert_eq!(view["instances"][0]["pluginID"], json!(valid.plugin_id)); + assert_eq!(view["issues"].as_array().expect("issues").len(), 1); + assert_eq!(view["issues"][0]["index"], json!(1)); + assert_eq!(view["issues"][0]["pluginID"], json!(invalid.plugin_id)); + assert_eq!(view["issues"][0]["kind"], json!("rejected")); +} + +#[test] +fn malformed_rejection_indices_do_not_blame_an_unrelated_package() { + let workspace = standard_workspace("rejection-index"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("rejection-index"); + let core = ScriptedCore { + responses: Mutex::new(vec![Ok(json!({ + "status":"ok", + "detection": { "instances":[], "shadowed":[], "rejected":[ + {"index":-1,"error":{"code":"invalid"}}, + {"index":99,"error":{"code":"invalid"}}, + {"index":"0","error":{"code":"invalid"}} + ] } + }))]), + }; + let mut registry = BuildRegistry::new(); + let view = view_json( + ®istry + .refresh(&core, &packages, &options, "main", &workspace.root()) + .expect("invalid rejection indices remain diagnostic data"), + ); + + for issue in view["issues"].as_array().expect("issues") { + assert!( + issue.get("pluginID").is_none(), + "unrelated package must not be blamed: {issue}" + ); + } +} + +#[test] +fn option_edits_persist_and_survive_a_registry_rebuild() { + let workspace = standard_workspace("persist"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("persist"); + let mut registry = BuildRegistry::new(); + + let view = view_json( + ®istry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"), + ); + let default_fingerprint = view["instances"][0]["optionFingerprint"] + .as_str() + .expect("fingerprint") + .to_string(); + + let view = view_json( + ®istry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect("option edit"), + ); + assert_eq!(option_value(&view, ".", "release"), &json!(true)); + assert_eq!( + options.stored(&workspace.root()).systems["cargo"].values["release"], + json!(true) + ); + + // A rebuilt registry (app restart) detects with the stored values and + // keeps the changed fingerprint. + let mut rebuilt = BuildRegistry::new(); + let view = view_json( + &rebuilt + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("re-detect"), + ); + assert_eq!(option_value(&view, ".", "release"), &json!(true)); + let fingerprint = view["instances"][0]["optionFingerprint"] + .as_str() + .expect("fingerprint"); + assert_ne!( + fingerprint, default_fingerprint, + "fingerprint follows the edit" + ); +} + +#[test] +fn an_edit_core_rejects_never_reaches_the_storage() { + let workspace = standard_workspace("dropped"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("dropped"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + + // An edit whose value Core drops (no model choice, wrong type) is a + // rejected write, not a silent rewrite to the effective value. + let error = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "features", + Some(json!(["vanished-feature"])), + ) + .expect_err("edit with invalid feature"); + assert!(error.contains("rejected by Core"), "{error}"); + assert!(options + .stored(&workspace.root()) + .systems + .get("cargo") + .map(|system| system.values.get("features").is_none()) + .unwrap_or(true)); + + // Refresh-time staleness still rewrites silently: a value stored by an + // older declaration that no instance accepts is replaced with the + // effective value so repeated detections agree. + let mut stale = StoredBuildOptions::default(); + stale.systems.insert( + "cargo".to_string(), + super::build_registry::StoredSystemOptions { + values: [("features".to_string(), json!(["vanished-feature"]))] + .into_iter() + .collect(), + empty_markers: Default::default(), + }, + ); + options.save(&workspace.root(), &stale).expect("seed"); + let view = view_json( + ®istry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("refresh with stale storage"), + ); + assert_eq!(option_value(&view, ".", "features"), &json!([])); + assert_eq!( + options.stored(&workspace.root()).systems["cargo"].values["features"], + json!([]) + ); +} + +#[test] +fn explicit_empty_multiselect_uses_a_marker_and_reset_clears_it() { + let workspace = TempWorkspace::new("marker", false); + workspace.write("Cargo.toml", &cargo_toml(&[("alpha", &[])])); + workspace.write("src/main.rs", "fn main() {}\n"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("marker"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + + let view = view_json( + ®istry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "features", + Some(json!([])), + ) + .expect("clear"), + ); + assert_eq!(option_value(&view, ".", "features"), &json!([])); + let stored = options.stored(&workspace.root()); + assert!( + stored.systems["cargo"] + .empty_markers + .get(".") + .expect("marker") + .contains("features"), + "explicit empty stored as a marker" + ); + + let view = view_json( + ®istry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "features", + None, + ) + .expect("reset"), + ); + assert_eq!(option_value(&view, ".", "features"), &json!([])); + let stored = options.stored(&workspace.root()); + assert!( + stored.systems["cargo"].values.get("features").is_none(), + "reset removes the shared override" + ); + assert!( + stored.systems["cargo"] + .empty_markers + .get(".") + .map(|markers| markers.is_empty()) + .unwrap_or(true), + "reset clears the marker" + ); +} + +#[test] +fn editing_one_instance_keeps_sibling_only_selections() { + let workspace = TempWorkspace::new("siblings", false); + workspace.write("a/Cargo.toml", &cargo_toml(&[("alpha", &[])])); + workspace.write("a/src/main.rs", "fn main() {}\n"); + workspace.write("b/Cargo.toml", &cargo_toml(&[("beta", &[])])); + workspace.write("b/src/main.rs", "fn main() {}\n"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("siblings"); + let mut registry = BuildRegistry::new(); + let view = view_json( + ®istry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"), + ); + assert_eq!(view["instances"].as_array().expect("instances").len(), 2); + + // Model choices are offers, not selections: a's alpha only reaches the + // shared store once the user selects it for a. + registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + "a", + "features", + Some(json!(["alpha"])), + ) + .expect("select alpha for a"); + + // Clearing b keeps a's selection in the shared store; b's own explicit + // empty lives in its marker. + let view = view_json( + ®istry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + "b", + "features", + Some(json!([])), + ) + .expect("clear b"), + ); + assert_eq!(option_value(&view, "a", "features"), &json!(["alpha"])); + assert_eq!(option_value(&view, "b", "features"), &json!([])); + let stored = options.stored(&workspace.root()); + assert_eq!( + stored.systems["cargo"].values["features"], + json!(["alpha"]), + "sibling selection survives in the shared store" + ); +} + +#[test] +fn workspace_switch_resets_sessions_and_rejects_stale_writes() { + let first = standard_workspace("switch-a"); + let second = standard_workspace("switch-b"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("switch"); + let mut registry = BuildRegistry::new(); + + let view = registry + .refresh(&RealCore, &packages, &options, "main", &first.root()) + .expect("detect first"); + assert_eq!(view.generation, 0); + + let view = registry + .refresh(&RealCore, &packages, &options, "main", &second.root()) + .expect("detect second"); + assert!(view.generation > 0, "workspace switch bumps the generation"); + + let error = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &first.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect_err("stale write rejected"); + assert!(error.contains("moved to another workspace"), "{error}"); + let error = registry + .sync_check( + &RealCore, + &packages, + "main", + &first.root(), + packages.plugin_id(), + "cargo", + ".", + ) + .expect_err("stale sync rejected"); + assert!(error.contains("moved to another workspace"), "{error}"); + + // Re-entering an already-visited workspace must rebuild the native + // window session before option editing and watcher checks resume. + let returned = registry + .refresh(&RealCore, &packages, &options, "main", &first.root()) + .expect("return to first"); + assert_eq!(returned.workspace_root, first.root()); + let edited = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &first.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect("edit after return"); + assert_eq!( + option_value(&view_json(&edited), ".", "release"), + &json!(true) + ); + assert!(options.stored(&second.root()).systems.is_empty()); + first.write("Cargo.toml", &cargo_toml(&[("after-return", &[])])); + let synced = registry + .sync_check( + &RealCore, + &packages, + "main", + &first.root(), + packages.plugin_id(), + "cargo", + ".", + ) + .expect("sync after return"); + assert_eq!(view_json(&synced)["instances"][0]["stale"], json!(true)); +} + +#[test] +fn disabling_the_plugin_clears_sessions_and_blocks_writes() { + let workspace = standard_workspace("disable"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("disable"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + + packages.set_enabled(false); + let view = registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect after disable"); + assert!(view.instances.is_empty(), "sessions cleared"); + + let error = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect_err("write after disable"); + assert!(error.contains("no longer an enabled plugin"), "{error}"); +} + +#[test] +fn file_changes_mark_the_model_stale_through_the_sync_fingerprint() { + let workspace = standard_workspace("stale"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("stale"); + let mut registry = BuildRegistry::new(); + let initial = registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + assert!(view_json(&initial)["instances"][0]["syncDigest"].is_string()); + + // The first watcher event after refresh must compare to the loaded + // model, not establish a baseline from already-changed files. + workspace.write("Cargo.toml", &cargo_toml(&[("extra", &[])])); + let view = view_json( + ®istry + .sync_check( + &RealCore, + &packages, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + ) + .expect("first sync after file change"), + ); + assert_eq!(view["instances"][0]["stale"], json!(true)); + assert!(view["instances"][0]["syncDigest"].is_string()); + + // Refresh rebuilds from disk and clears the stale flag. + let view = view_json( + ®istry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("reload"), + ); + assert_eq!(view["instances"][0]["stale"], json!(false)); + let baseline = view["instances"][0]["syncDigest"].clone(); + let unchanged = registry + .sync_check( + &RealCore, + &packages, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + ) + .expect("unchanged after reload"); + assert_eq!(view_json(&unchanged)["instances"][0]["stale"], json!(false)); + assert_eq!( + view_json(&unchanged)["instances"][0]["syncDigest"], + baseline + ); + + workspace.write("Cargo.toml", &cargo_toml(&[("next-change", &[])])); + let changed_again = registry + .sync_check( + &RealCore, + &packages, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + ) + .expect("first event after the second reload"); + assert_eq!( + view_json(&changed_again)["instances"][0]["stale"], + json!(true) + ); +} + +/// Changes disk at a deterministic Core return boundary, with no watcher, +/// sleep, thread, or assumption about filesystem timestamp resolution. +struct ChangeAfterCoreCall<'a> { + workspace: &'a TempWorkspace, + command: &'static str, + changed: AtomicBool, +} + +impl BuildCoreExecutor for ChangeAfterCoreCall<'_> { + fn execute(&self, command: &str, payload: Value) -> Result { + let response = RealCore.execute(command, payload)?; + if command == self.command && !self.changed.swap(true, Ordering::SeqCst) { + self.workspace + .write("Cargo.toml", &cargo_toml(&[("new-feature", &[])])); + } + Ok(response) + } +} + +#[test] +fn a_change_between_detection_and_the_baseline_is_read_into_the_model() { + let workspace = standard_workspace("baseline-before-model"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("baseline-before-model"); + let core = ChangeAfterCoreCall { + workspace: &workspace, + command: "build.detect", + changed: AtomicBool::new(false), + }; + let mut registry = BuildRegistry::new(); + let loaded = registry + .refresh(&core, &packages, &options, "main", &workspace.root()) + .expect("refresh"); + let loaded = view_json(&loaded); + assert_eq!(loaded["instances"][0]["stale"], json!(false)); + assert!( + loaded["instances"][0]["options"] + .as_array() + .expect("options") + .iter() + .any(|option| { + option["id"] == "features" + && option["choices"] + .as_array() + .expect("choices") + .iter() + .any(|choice| choice["value"] == "new-feature") + }), + "the published model rereads the bytes whose fingerprint is stored" + ); +} + +#[test] +fn a_change_while_the_model_loads_is_already_stale_at_refresh_completion() { + let workspace = standard_workspace("change-during-model"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("change-during-model"); + let core = ChangeAfterCoreCall { + workspace: &workspace, + command: "build.decodeModel", + changed: AtomicBool::new(false), + }; + let mut registry = BuildRegistry::new(); + let loaded = registry + .refresh(&core, &packages, &options, "main", &workspace.root()) + .expect("refresh"); + assert_eq!(view_json(&loaded)["instances"][0]["stale"], json!(true)); + let refreshed = registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("reload stable files"); + assert_eq!(view_json(&refreshed)["instances"][0]["stale"], json!(false)); +} + +#[test] +fn windows_keep_their_own_sessions_for_the_same_workspace() { + let workspace = standard_workspace("windows"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("windows"); + let mut registry = BuildRegistry::new(); + registry + .refresh( + &RealCore, + &packages, + &options, + "window-a", + &workspace.root(), + ) + .expect("detect a"); + registry + .refresh( + &RealCore, + &packages, + &options, + "window-b", + &workspace.root(), + ) + .expect("detect b"); + + registry + .set_option( + &RealCore, + &packages, + &options, + "window-a", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect("edit in a"); + + // Window b's stored session still shows the value from its own last + // refresh until that window refreshes itself. + let view_b = view_json(®istry.view("window-b")); + assert_eq!(option_value(&view_b, ".", "release"), &json!(false)); + let view_b = view_json( + ®istry + .refresh( + &RealCore, + &packages, + &options, + "window-b", + &workspace.root(), + ) + .expect("refresh b"), + ); + assert_eq!(option_value(&view_b, ".", "release"), &json!(true)); +} + +#[test] +fn a_non_directory_root_detects_as_empty_without_panicking() { + // Core tolerates non-directory roots and reports no instances; the + // registry must surface that as an empty view, not an error. + let workspace = standard_workspace("invalid"); + let file_root = workspace.root.join("Cargo.toml"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("invalid"); + let mut registry = BuildRegistry::new(); + + let view = registry + .refresh( + &RealCore, + &packages, + &options, + "main", + &file_root.to_string_lossy(), + ) + .expect("core answers for non-directory roots"); + assert!(view.instances.is_empty()); +} + +#[test] +fn core_rejections_and_broken_envelopes_surface_as_errors() { + let workspace = standard_workspace("envelope"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("envelope"); + + let mut registry = BuildRegistry::new(); + let error = registry + .refresh( + &ScriptedCore::rejected("plugin.build.invalidSelection", "bad request"), + &packages, + &options, + "main", + &workspace.root(), + ) + .expect_err("rejected detect"); + assert!(error.contains("plugin.build.invalidSelection"), "{error}"); + + let mut registry = BuildRegistry::new(); + let error = registry + .refresh( + &ScriptedCore::broken(), + &packages, + &options, + "main", + &workspace.root(), + ) + .expect_err("unexpected status"); + assert!(error.contains("unexpected status"), "{error}"); +} + +#[test] +fn an_invalid_option_value_is_rejected_without_touching_storage() { + let workspace = standard_workspace("invalid-value"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("invalid-value"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + + let error = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!("yes")), + ) + .expect_err("a string is not a boolean value"); + assert!(error.contains("rejected by Core"), "{error}"); + + // The storage and the visible model keep the previous state. + let stored = options.stored(&workspace.root()); + assert!(stored + .systems + .get("cargo") + .map(|system| system.values.get("release").is_none()) + .unwrap_or(true)); + let view = view_json(®istry.view("main")); + assert_eq!(option_value(&view, ".", "release"), &json!(false)); + + // An unknown multi-select choice is likewise a rejected edit. + let error = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "features", + Some(json!(["vanished-feature"])), + ) + .expect_err("unknown choice"); + assert!(error.contains("rejected by Core"), "{error}"); + assert!( + options + .stored(&workspace.root()) + .systems + .get("cargo") + .map(|system| system.values.get("features").is_none()) + .unwrap_or(true), + "storage stayed clean after the rejected edit" + ); +} + +/// Persistence double whose next save can be made to fail. +struct FlakySave { + inner: TempOptions, + fail_next: Mutex, +} + +impl FlakySave { + fn new(name: &str) -> Self { + FlakySave { + inner: TempOptions::new(name), + fail_next: Mutex::new(false), + } + } + + fn fail_next_save(&self) { + *self.fail_next.lock().expect("fail flag") = true; + } +} + +impl BuildOptionsPersistence for FlakySave { + fn load(&self, workspace_root: &str) -> Result { + self.inner.load(workspace_root) + } + + fn save(&self, workspace_root: &str, options: &StoredBuildOptions) -> Result<(), String> { + if *self.fail_next.lock().expect("fail flag") { + *self.fail_next.lock().expect("fail flag") = false; + return Err("disk full".to_string()); + } + self.inner.save(workspace_root, options) + } +} + +#[test] +fn a_failed_save_leaves_the_previous_options_untouched() { + let workspace = standard_workspace("save-fail"); + let packages = FixedPackages::one(static_fixture()); + let options = FlakySave::new("save-fail"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + + options.fail_next_save(); + let error = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect_err("save failure surfaces"); + assert!(error.contains("disk full"), "{error}"); + assert!( + options + .inner + .stored(&workspace.root()) + .systems + .get("cargo") + .map(|system| system.values.get("release").is_none()) + .unwrap_or(true), + "storage unchanged after the failed save" + ); +} + +/// Core double with an ordered response script and a record of payloads. +struct ScriptedSequence { + responses: Mutex>>, + seen: Mutex>, +} + +impl ScriptedSequence { + fn ok_then_detect_error() -> Self { + ScriptedSequence { + responses: Mutex::new(vec![ + // set_option validation: resolveOptions ok. + Ok(json!({ + "status": "ok", + "options": { "options": [ + { "id": "release", "kind": "boolean", "value": true, "choices": [] }, + { "id": "features", "kind": "multiSelect", "value": [], "choices": [] }, + { "id": "offline", "kind": "boolean", "value": false, "choices": [] } + ], "dropped": [], "fingerprint": "validated" } + })), + // The re-detection after the save fails. + Ok(json!({ + "status": "rejected", + "error": { "code": "plugin.build.invalidSelection", "message": "boom" } + })), + ]), + seen: Mutex::new(Vec::new()), + } + } + + fn sync_response() -> Self { + ScriptedSequence { + responses: Mutex::new(vec![Ok(json!({ + "status": "ok", + "sync": { "fingerprint": "synced", "watch": [] } + }))]), + seen: Mutex::new(Vec::new()), + } + } +} + +impl BuildCoreExecutor for ScriptedSequence { + fn execute(&self, command: &str, payload: Value) -> Result { + self.seen + .lock() + .expect("seen") + .push((command.to_string(), payload.clone())); + self.responses + .lock() + .expect("scripted responses") + .pop() + .expect("scripted response for every call") + } +} + +#[test] +fn a_failed_redetection_rolls_back_the_saved_options() { + let workspace = standard_workspace("rollback"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("rollback"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + let generation_before = registry.view("main").generation; + + let core = ScriptedSequence::ok_then_detect_error(); + let error = registry + .set_option( + &core, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect_err("re-detection failure surfaces"); + assert!(error.contains("plugin.build.invalidSelection"), "{error}"); + + // The saved candidate was rolled back and the sessions are untouched. + let stored = options.stored(&workspace.root()); + assert!( + stored + .systems + .get("cargo") + .map(|system| system.values.get("release").is_none()) + .unwrap_or(true), + "storage rolled back to the pre-edit values" + ); + let view = view_json(®istry.view("main")); + assert_eq!(option_value(&view, ".", "release"), &json!(false)); + assert_eq!(registry.view("main").generation, generation_before); +} + +#[test] +fn a_replaced_package_refuses_sync_and_option_writes() { + let workspace = standard_workspace("replaced"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("replaced"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + + packages.set_version("0.2.0"); + let error = registry + .sync_check( + &RealCore, + &packages, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + ) + .expect_err("version change refuses sync"); + assert!(error.contains("changed since"), "{error}"); + let error = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect_err("version change refuses writes"); + assert!(error.contains("changed since"), "{error}"); + + // A disabled package refuses the same queries. + packages.set_version("0.1.0"); + packages.set_enabled(false); + let error = registry + .sync_check( + &RealCore, + &packages, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + ) + .expect_err("disabled package refuses sync"); + assert!(error.contains("no longer an enabled plugin"), "{error}"); +} + +#[test] +fn drop_plugin_revokes_sessions_and_blocks_writes() { + let workspace = standard_workspace("revoke"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("revoke"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + + registry.drop_plugin(packages.plugin_id()); + let view = view_json(®istry.view("main")); + assert!(view["instances"].as_array().expect("instances").is_empty()); + + let error = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect_err("revoked session refuses writes"); + assert!(error.contains("no build instance"), "{error}"); +} + +#[test] +fn closing_the_workspace_clears_sessions_before_a_reopen() { + let workspace = standard_workspace("close"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("close"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + + registry.close_workspace("main", &workspace.root()); + let view = view_json(®istry.view("main")); + assert!(view["instances"].as_array().expect("instances").is_empty()); + assert_eq!( + view["workspaceRoot"], "", + "a closed root is no longer readable" + ); + let generation = view["generation"].as_u64().expect("generation"); + assert!(generation > 0, "close bumps the generation"); + + let error = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect_err("closed workspace refuses writes"); + assert!(error.contains("moved to another workspace"), "{error}"); + assert!(registry + .check_read_snapshot("main", &workspace.root(), generation) + .is_err()); +} + +#[test] +fn sync_sends_the_current_verified_manifest_not_the_session_snapshot() { + let workspace = standard_workspace("fresh-manifest"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("fresh-manifest"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + + // Same-version reinstall: the bytes and therefore the manifest change + // under the same plugin ID and version. + let mut replaced = static_fixture(); + replaced["displayName"] = json!("Reinstalled Fixture"); + packages.set_manifest(replaced.clone()); + + let core = ScriptedSequence::sync_response(); + registry + .sync_check( + &core, + &packages, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + ) + .expect("sync succeeds with the fresh package"); + + let seen = core.seen.lock().expect("seen"); + let (_, payload) = seen + .iter() + .find(|(command, _)| command == "build.syncFingerprint") + .expect("sync request recorded"); + assert_eq!( + payload["manifest"]["displayName"], + json!("Reinstalled Fixture") + ); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/details.rs b/windows/tauri/src-tauri/src/plugin_host/details.rs new file mode 100644 index 0000000000..d0d02c91d7 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/details.rs @@ -0,0 +1,825 @@ +//! Read-only detail model for packages already discovered by the plugin host. +//! +//! Callers identify a cached package by plugin ID and catalog source; they +//! never submit a filesystem path. Every live package read holds the same +//! lifecycle lease used by launch probes, stays inside the owning package +//! root, and re-verifies the bytes before reporting trust or provenance. + +use super::signature::{self, InstallOrigin}; +use super::store::{self, InstallationRecord}; +use super::{CatalogRejection, PluginHostState, ScannedPackage}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager}; + +/// Stable frontend-facing failure from the detail read boundary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginDetailsError { + pub code: String, + pub message: String, +} + +impl PluginDetailsError { + fn new(code: &str, message: impl Into) -> Self { + Self { + code: code.to_string(), + message: message.into(), + } + } +} + +/// Complete detail snapshot for one package identity. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginDetails { + pub identity: PluginDetailsIdentity, + pub display_name: String, + pub vendor_name: Option, + pub description: Option, + pub localized_descriptions: BTreeMap, + pub api_version: u32, + pub enabled: bool, + pub status: &'static str, + pub rejection: Option, + pub settings: Vec, + pub primary_language_id: Option, + pub features: Vec, + pub permissions: Vec, + /// Full canonical program objects returned by Core install review. + pub programs: Vec, + pub versions: PluginVersionDetails, + pub operations: PluginDetailOperations, +} + +/// Selection identity that must travel with every asynchronous response. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginDetailsIdentity { + pub plugin_id: String, + pub version: String, + pub source: String, +} + +/// One declaration-derived feature and whether this host can execute it. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginFeature { + pub kind: &'static str, + pub subject: String, + pub title: String, + /// `available` means the corresponding capability is implemented by this + /// host. `declaredOnly` is informative and never promises execution. + pub availability: &'static str, + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginVersionDetails { + pub current: VersionProvenance, + pub previous: Option, + /// Marketplace index data is supplied by M2. Until it is connected, the + /// detail surface must not invent an update or "latest" state. + pub update_status: &'static str, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VersionProvenance { + pub version: String, + pub origin: &'static str, + pub channel: &'static str, + pub source_id: Option, + pub source_name: Option, + pub repository: Option, + pub file_name: Option, + pub archive_sha256: Option, + pub content_digest: Option, + pub verification: ProvenanceVerification, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProvenanceVerification { + /// `publisherSignature`, `hostSeal`, `approvedDigest`, `rejected`, or + /// `unknown`. Only the first three are successful verification results. + pub status: &'static str, + pub key_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginDetailOperations { + pub can_set_enabled: bool, + pub can_uninstall: bool, + pub can_rollback: bool, +} + +/// Reads one package already present in the host catalog. `source` resolves +/// duplicate bundled/installed IDs without exposing a path-shaped selector. +#[tauri::command] +pub async fn declarative_plugins_details( + app: AppHandle, + plugin_id: String, + source: String, +) -> Result { + // Package digests can cover a bundled language server; keep their file IO + // off the UI and async executor threads while the worker owns its lease. + tauri::async_runtime::spawn_blocking(move || read_catalog_details(app, plugin_id, source)) + .await + .map_err(|error| PluginDetailsError::new("plugin.details.readFailed", error.to_string()))? +} + +fn read_catalog_details( + app: AppHandle, + plugin_id: String, + source: String, +) -> Result { + if source != "bundled" && source != "installed" { + return Err(PluginDetailsError::new( + "plugin.details.invalidSource", + "Plugin details can only be read from the bundled or installed catalog.", + )); + } + let _lease = app + .state::() + .acquire(&plugin_id) + .map_err(|message| PluginDetailsError::new("plugin.details.changing", message))?; + let package = app + .state::() + .packages() + .into_iter() + .find(|package| package.entry.plugin_id == plugin_id && package.entry.source == source) + .ok_or_else(|| { + PluginDetailsError::new( + "plugin.details.notFound", + "The selected plugin is no longer present. Refresh the plugin list.", + ) + })?; + let install_root = store::install_root(&app) + .map_err(|message| PluginDetailsError::new("plugin.details.storageUnavailable", message))?; + let bundled_root = store::bundled_root(&app) + .map_err(|message| PluginDetailsError::new("plugin.details.storageUnavailable", message))?; + build_details(&package, &install_root, &bundled_root) +} + +pub(crate) fn build_details( + package: &ScannedPackage, + install_root: &Path, + bundled_root: &Path, +) -> Result { + let expected_root = match package.entry.source { + "bundled" => bundled_root, + "installed" => install_root, + _ => { + return Err(PluginDetailsError::new( + "plugin.details.invalidSource", + "The cached plugin source is not supported by the detail reader.", + )) + } + }; + ensure_within(package.root.as_path(), expected_root)?; + + let record = if package.entry.source == "installed" { + read_installation_record(install_root, &package.entry.plugin_id) + } else { + Ok(None) + }; + if package.entry.status == "rejected" { + let record = record.ok().flatten(); + return Ok(quarantined_details(package, record.as_ref())); + } + let record = record.map_err(|message| { + PluginDetailsError::new("plugin.details.installationRecordChanged", message) + })?; + if package.entry.source == "installed" && record.is_none() { + return Err(PluginDetailsError::new( + "plugin.details.installationRecordChanged", + "The installation record disappeared after the catalog was scanned. Refresh the plugin list.", + )); + } + + // The scan cache selects the package, but detail content comes from the + // live package under the lease. This also catches edits after a scan. + let manifest = read_manifest(&package.root)?; + let normalized = super::scan::validate_manifest(&manifest).map_err(|rejection| { + PluginDetailsError::new( + "plugin.details.packageChanged", + format!("{}: {}", rejection.code, rejection.message), + ) + })?; + if normalized["id"].as_str() != Some(package.entry.plugin_id.as_str()) + || normalized["version"].as_str() != Some(package.entry.version.as_str()) + { + return Err(PluginDetailsError::new( + "plugin.details.packageChanged", + "The package identity changed after the catalog was scanned. Refresh the plugin list.", + )); + } + let trust = package.trust.as_ref().ok_or_else(|| { + PluginDetailsError::new( + "plugin.details.trustUnavailable", + "The accepted package has no verified trust evidence.", + ) + })?; + if let Some(record) = &record { + if record.schema_version != 2 + || record.plugin_id != package.entry.plugin_id + || record.version != package.entry.version + || record.origin != Some(trust.origin) + || record.active_content_digest() != trust.expected_content_digest.as_deref() + { + return Err(PluginDetailsError::new( + "plugin.details.installationRecordChanged", + "The active installation no longer matches the catalog package. Refresh the plugin list.", + )); + } + } + signature::verify_for_origin( + &package.root, + &package.entry.plugin_id, + &package.entry.version, + &manifest, + trust.origin, + trust.expected_content_digest.as_deref(), + Some(bundled_root), + &signature::trusted_keys(), + ) + .map_err(|error| { + PluginDetailsError::new( + "plugin.details.packageChanged", + format!( + "The package no longer passes verification: {}", + error.message() + ), + ) + })?; + + let approved_programs = record.as_ref().map(active_approved_programs).transpose()?; + let review = install_review(&manifest, approved_programs.as_deref())?; + let current = current_provenance( + &package.entry.version, + trust.origin, + record.as_ref(), + &package.root, + )?; + let previous = record.as_ref().and_then(previous_provenance); + let settings = normalized["settings"] + .as_array() + .cloned() + .unwrap_or_default(); + Ok(PluginDetails { + identity: PluginDetailsIdentity { + plugin_id: package.entry.plugin_id.clone(), + version: package.entry.version.clone(), + source: package.entry.source.to_string(), + }, + display_name: normalized["displayName"] + .as_str() + .unwrap_or(package.entry.display_name.as_str()) + .to_string(), + vendor_name: normalized["vendor"]["displayName"] + .as_str() + .map(str::to_string), + description: normalized["description"].as_str().map(str::to_string), + localized_descriptions: localized_descriptions(&normalized), + api_version: package.entry.api_version, + enabled: record + .as_ref() + .map_or(package.entry.enabled, |record| record.enabled), + status: "accepted", + rejection: None, + primary_language_id: normalized["languageServers"] + .as_array() + .and_then(|servers| servers.first()) + .and_then(|server| server["languageID"].as_str()) + .map(str::to_string), + settings, + features: features(&normalized), + permissions: review.permissions, + programs: review.programs, + versions: PluginVersionDetails { + current, + previous, + update_status: "unknown", + }, + operations: PluginDetailOperations { + can_set_enabled: package.entry.source == "installed", + can_uninstall: package.entry.source == "installed", + can_rollback: record + .as_ref() + .and_then(|record| record.previous_version.as_ref()) + .is_some(), + }, + }) +} + +fn quarantined_details( + package: &ScannedPackage, + record: Option<&InstallationRecord>, +) -> PluginDetails { + let current = VersionProvenance { + version: package.entry.version.clone(), + origin: "unknown", + channel: "unknown", + source_id: None, + source_name: None, + repository: None, + file_name: None, + archive_sha256: None, + content_digest: None, + verification: ProvenanceVerification { + status: "rejected", + key_id: None, + }, + }; + PluginDetails { + identity: PluginDetailsIdentity { + plugin_id: package.entry.plugin_id.clone(), + version: package.entry.version.clone(), + source: package.entry.source.to_string(), + }, + display_name: package.entry.display_name.clone(), + vendor_name: None, + description: None, + localized_descriptions: BTreeMap::new(), + api_version: package.entry.api_version, + enabled: false, + status: "rejected", + rejection: package.entry.rejection.clone(), + settings: Vec::new(), + primary_language_id: None, + features: Vec::new(), + permissions: Vec::new(), + programs: Vec::new(), + versions: PluginVersionDetails { + current, + previous: record.and_then(previous_provenance), + update_status: "unknown", + }, + operations: PluginDetailOperations { + can_set_enabled: false, + can_uninstall: package.entry.source == "installed", + can_rollback: record + .and_then(|record| record.previous_version.as_ref()) + .is_some(), + }, + } +} + +struct InstallReview { + permissions: Vec, + programs: Vec, +} + +fn install_review( + manifest: &Value, + approved_programs: Option<&[Value]>, +) -> Result { + let outcome = super::scan::core_execute( + "plugins.marketplace.installReview", + json!({ + "manifest": manifest, + "hostCapabilities": super::host_capabilities(), + "approvedPrograms": approved_programs, + }), + ) + .map_err(|message| PluginDetailsError::new("plugin.details.reviewUnavailable", message))?; + if outcome["status"] != json!("ok") { + return Err(PluginDetailsError::new( + "plugin.details.reviewRejected", + outcome["error"]["message"] + .as_str() + .unwrap_or("Core could not review the installed package."), + )); + } + if approved_programs.is_some() + && outcome["review"]["newPrograms"] + .as_array() + .is_some_and(|programs| !programs.is_empty()) + { + return Err(PluginDetailsError::new( + "plugin.details.approvalChanged", + "The program declarations no longer match the installation approval. Reinstall the plugin before using it.", + )); + } + Ok(InstallReview { + permissions: strings(&outcome["review"]["permissions"]), + programs: outcome["review"]["programs"] + .as_array() + .cloned() + .unwrap_or_default(), + }) +} + +fn read_manifest(root: &Path) -> Result { + let text = std::fs::read_to_string(root.join("plugin.json")).map_err(|error| { + PluginDetailsError::new( + "plugin.details.packageChanged", + format!("plugin.json could not be read: {error}"), + ) + })?; + serde_json::from_str(&text).map_err(|error| { + PluginDetailsError::new( + "plugin.details.packageChanged", + format!("plugin.json is no longer valid JSON: {error}"), + ) + }) +} + +fn read_installation_record( + install_root: &Path, + plugin_id: &str, +) -> Result, String> { + let component = Path::new(plugin_id); + if component + .parent() + .is_some_and(|parent| parent != Path::new("")) + || component.file_name().and_then(|value| value.to_str()) != Some(plugin_id) + || matches!(plugin_id, "" | "." | "..") + { + return Err("The selected plugin ID is not a safe store component.".to_string()); + } + let path = install_root.join(plugin_id).join("installation.json"); + if path.exists() { + ensure_within(&path, install_root).map_err(|error| error.message)?; + } + match std::fs::read_to_string(&path) { + Ok(text) => serde_json::from_str(&text) + .map(Some) + .map_err(|error| format!("installation.json could not be decoded: {error}")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("installation.json could not be read: {error}")), + } +} + +fn ensure_within(package_root: &Path, expected_root: &Path) -> Result<(), PluginDetailsError> { + let root = expected_root.canonicalize().map_err(|error| { + PluginDetailsError::new( + "plugin.details.storageUnavailable", + format!("The plugin store could not be resolved: {error}"), + ) + })?; + let package = package_root.canonicalize().map_err(|error| { + PluginDetailsError::new( + "plugin.details.packageChanged", + format!("The selected package could not be resolved: {error}"), + ) + })?; + if !package.starts_with(&root) || package == root { + return Err(PluginDetailsError::new( + "plugin.details.pathBoundary", + "The selected package is outside its catalog store.", + )); + } + Ok(()) +} + +fn active_approved_programs(record: &InstallationRecord) -> Result, PluginDetailsError> { + let values = match (&record.marketplace, &record.disk) { + (Some(marketplace), None) => &marketplace.approved_programs, + (None, Some(disk)) => &disk.approved_programs, + _ => { + return Err(PluginDetailsError::new( + "plugin.details.provenanceUnavailable", + "The active installation has no single approved program record.", + )) + } + }; + values + .iter() + .map(|program| { + serde_json::from_str(program).map_err(|error| { + PluginDetailsError::new( + "plugin.details.provenanceUnavailable", + format!("An approved program declaration is unreadable: {error}"), + ) + }) + }) + .collect() +} + +fn current_provenance( + version: &str, + origin: InstallOrigin, + record: Option<&InstallationRecord>, + package_root: &Path, +) -> Result { + let key_id = if signature::has_signature_file(package_root) { + Some( + signature::read_signature_document(package_root) + .map_err(|error| { + PluginDetailsError::new( + "plugin.details.packageChanged", + format!( + "The verified signature document is unreadable: {}", + error.message() + ), + ) + })? + .key_id, + ) + } else { + None + }; + let verification = ProvenanceVerification { + status: if key_id.is_some() { + "publisherSignature" + } else if origin == InstallOrigin::Bundled { + "hostSeal" + } else { + "approvedDigest" + }, + key_id, + }; + let mut value = VersionProvenance { + version: version.to_string(), + origin: origin_name(origin), + channel: if origin == InstallOrigin::Bundled { + "bundled" + } else { + "unknown" + }, + source_id: None, + source_name: None, + repository: None, + file_name: None, + archive_sha256: None, + content_digest: None, + verification, + }; + if let Some(record) = record { + apply_provenance( + &mut value, + record.marketplace.as_ref(), + record.disk.as_ref(), + ); + } + Ok(value) +} + +fn previous_provenance(record: &InstallationRecord) -> Option { + let version = record.previous_version.as_ref()?; + let origin = record.previous_origin?; + let mut value = VersionProvenance { + version: version.clone(), + origin: origin_name(origin), + channel: if origin == InstallOrigin::Bundled { + "bundled" + } else { + "unknown" + }, + source_id: None, + source_name: None, + repository: None, + file_name: None, + archive_sha256: None, + content_digest: None, + // Previous bytes are verified only when rollback begins; the record + // is provenance, not proof that the dormant directory is unchanged. + verification: ProvenanceVerification { + status: "unknown", + key_id: None, + }, + }; + apply_provenance( + &mut value, + record.previous_marketplace.as_ref(), + record.previous_disk.as_ref(), + ); + Some(value) +} + +fn apply_provenance( + target: &mut VersionProvenance, + marketplace: Option<&store::MarketplaceProvenance>, + disk: Option<&store::DiskProvenance>, +) { + match (marketplace, disk) { + (Some(marketplace), None) => { + target.channel = "marketplace"; + target.source_id = Some(marketplace.source_id.clone()); + target.source_name = Some(marketplace.source_name.clone()); + target.repository = Some(marketplace.repository.clone()); + target.archive_sha256 = Some(marketplace.archive_sha256.clone()); + target.content_digest = Some(marketplace.content_digest.clone()); + } + (None, Some(disk)) => { + target.channel = "disk"; + target.file_name = Some(disk.file_name.clone()); + target.archive_sha256 = disk.archive_sha256.clone(); + target.content_digest = Some(disk.content_digest.clone()); + } + _ => {} + } +} + +fn origin_name(origin: InstallOrigin) -> &'static str { + match origin { + InstallOrigin::Bundled => "bundled", + InstallOrigin::Marketplace => "marketplace", + InstallOrigin::Community => "community", + } +} + +fn localized_descriptions(plugin: &Value) -> BTreeMap { + plugin["localizedDescriptions"] + .as_object() + .map(|descriptions| { + descriptions + .iter() + .filter_map(|(locale, value)| { + value + .as_str() + .map(|description| (locale.clone(), description.to_string())) + }) + .collect() + }) + .unwrap_or_default() +} + +fn features(plugin: &Value) -> Vec { + let language_titles: HashMap = plugin["languages"] + .as_array() + .into_iter() + .flatten() + .filter_map(|language| { + Some(( + language["id"].as_str()?.to_string(), + language["displayName"].as_str()?.to_string(), + )) + }) + .collect(); + let mut result = Vec::new(); + for language in array(plugin, "languages") { + push_feature( + &mut result, + "language", + &language["id"], + &language["displayName"], + "languages", + Some(json!({ + "fileExtensions": language["fileExtensions"], + "fileNames": language["fileNames"], + })), + ); + } + for server in array(plugin, "languageServers") { + let id = server["languageID"].as_str().unwrap_or_default(); + result.push(PluginFeature { + kind: "languageServer", + subject: id.to_string(), + title: language_titles + .get(id) + .cloned() + .unwrap_or_else(|| id.to_string()), + availability: availability("languageServers"), + detail: server + .get("launch") + .and_then(|launch| launch.get("kind")) + .cloned() + .map(|kind| json!({ "launchKind": kind })), + }); + } + for provider in array(plugin, "executionProviders") { + let id = provider["languageID"].as_str().unwrap_or_default(); + let title = language_titles + .get(id) + .cloned() + .unwrap_or_else(|| id.to_string()); + if provider.get("run").is_some() { + result.push(PluginFeature { + kind: "run", + subject: id.to_string(), + title: title.clone(), + availability: availability("executionProviders"), + detail: None, + }); + } + if provider.get("test").is_some() { + result.push(PluginFeature { + kind: "test", + subject: id.to_string(), + title, + availability: availability("executionProviders"), + detail: None, + }); + } + } + for build in array(plugin, "buildSystems") { + push_feature( + &mut result, + "buildSystem", + &build["id"], + &build["displayName"], + "buildSystems", + None, + ); + } + for generator in array(plugin, "projectGenerators") { + push_feature( + &mut result, + "projectGenerator", + &generator["id"], + &generator["displayName"], + "projectGenerators", + None, + ); + } + if let Some(ui) = plugin.get("ui") { + for window in array(ui, "toolWindows") { + push_feature( + &mut result, + "toolWindow", + &window["id"], + &window["title"], + "ui", + None, + ); + } + for command in array(ui, "commands") { + push_feature( + &mut result, + "command", + &command["id"], + &command["title"], + "ui", + None, + ); + } + } + result.sort_by(|left, right| { + feature_rank(left.kind) + .cmp(&feature_rank(right.kind)) + .then_with(|| left.title.cmp(&right.title)) + .then_with(|| left.subject.cmp(&right.subject)) + }); + result +} + +fn push_feature( + result: &mut Vec, + kind: &'static str, + subject: &Value, + title: &Value, + capability: &str, + detail: Option, +) { + let subject = subject.as_str().unwrap_or_default().to_string(); + result.push(PluginFeature { + kind, + title: title.as_str().unwrap_or(subject.as_str()).to_string(), + subject, + availability: availability(capability), + detail, + }); +} + +fn availability(capability: &str) -> &'static str { + if super::host_capabilities() + .get(capability) + .and_then(Value::as_array) + .is_some_and(|versions| !versions.is_empty()) + { + "available" + } else { + "declaredOnly" + } +} + +fn feature_rank(kind: &str) -> usize { + [ + "language", + "languageServer", + "run", + "test", + "buildSystem", + "projectGenerator", + "toolWindow", + "command", + ] + .iter() + .position(|candidate| *candidate == kind) + .unwrap_or(usize::MAX) +} + +fn array<'a>(value: &'a Value, key: &str) -> impl Iterator { + value[key].as_array().into_iter().flatten() +} + +fn strings(value: &Value) -> Vec { + value + .as_array() + .into_iter() + .flatten() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() +} + +#[cfg(test)] +pub(crate) fn installation_record_path(root: &Path, plugin_id: &str) -> PathBuf { + root.join(plugin_id).join("installation.json") +} diff --git a/windows/tauri/src-tauri/src/plugin_host/details_tests.rs b/windows/tauri/src-tauri/src/plugin_host/details_tests.rs new file mode 100644 index 0000000000..ae98b030d0 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/details_tests.rs @@ -0,0 +1,355 @@ +//! Regression coverage for the package-backed plugin detail model. + +use super::details::{self, PluginDetailsError}; +use super::scan; +use super::signature; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/contracts/plugins/v1/fixtures/language-packaged-server.plugin.json" +); + +struct TestDirectory(PathBuf); + +impl TestDirectory { + fn new(label: &str) -> Self { + static NEXT: AtomicU64 = AtomicU64::new(1); + let path = std::env::temp_dir().join(format!( + "lithe-plugin-details-{label}-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&path).expect("test directory"); + Self(path) + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn fixture() -> Value { + serde_json::from_str(&std::fs::read_to_string(FIXTURE).expect("fixture is readable")) + .expect("fixture is JSON") +} + +fn community_manifest() -> Value { + let mut manifest = fixture(); + manifest["id"] = json!("io.example.details-fixture"); + manifest["displayName"] = json!("Detail Fixture"); + manifest["vendor"]["signatureRequirement"] = json!("none"); + manifest +} + +fn review_programs(manifest: &Value) -> Vec { + let outcome = scan::core_execute( + "plugins.marketplace.installReview", + json!({ + "manifest": manifest, + "hostCapabilities": super::host_capabilities(), + }), + ) + .expect("Core install review"); + assert_eq!(outcome["status"], json!("ok"), "review outcome: {outcome}"); + outcome["review"]["programs"] + .as_array() + .expect("programs") + .iter() + .map(|program| serde_json::to_string(program).expect("canonical program JSON")) + .collect() +} + +fn write_installed_package(install_root: &Path, manifest: &Value, enabled: bool) -> PathBuf { + let plugin_id = manifest["id"].as_str().expect("plugin ID"); + let version = manifest["version"].as_str().expect("version"); + let package_root = install_root.join(plugin_id).join(version); + std::fs::create_dir_all(&package_root).expect("package directory"); + std::fs::write( + package_root.join("plugin.json"), + serde_json::to_vec_pretty(manifest).expect("manifest bytes"), + ) + .expect("manifest file"); + let content_digest = signature::content_digest(&package_root).expect("content digest"); + let record = json!({ + "schemaVersion": 2, + "pluginId": plugin_id, + "version": version, + "previousVersion": "0.0.9", + "enabled": enabled, + "origin": "community", + "previousOrigin": "marketplace", + "disk": { + "fileName": "detail-fixture.zip", + "archiveSha256": "archive-current", + "contentDigest": content_digest, + "approvedPrograms": review_programs(manifest), + }, + "previousMarketplace": { + "sourceId": "lithe-store", + "sourceName": "Lithe Store", + "repository": "https://example.invalid/plugins", + "archiveSha256": "archive-previous", + "contentDigest": "digest-previous", + "approvedPrograms": [], + }, + }); + std::fs::write( + details::installation_record_path(install_root, plugin_id), + serde_json::to_vec_pretty(&record).expect("record bytes"), + ) + .expect("installation record"); + package_root +} + +fn find_package<'a>(packages: &'a [super::ScannedPackage], id: &str) -> &'a super::ScannedPackage { + packages + .iter() + .find(|package| package.entry.plugin_id == id) + .expect("scanned package") +} + +#[test] +fn installed_details_use_core_review_and_recorded_current_and_previous_provenance() { + let root = TestDirectory::new("installed"); + let bundled_root = root.0.join("bundled"); + let install_root = root.0.join("installed"); + std::fs::create_dir_all(&bundled_root).expect("bundled root"); + let manifest = community_manifest(); + write_installed_package(&install_root, &manifest, false); + + let packages = scan::build_catalog_from(&bundled_root, &install_root).expect("catalog"); + let package = find_package(&packages, "io.example.details-fixture"); + assert_eq!(package.entry.status, "accepted"); + let details = details::build_details(package, &install_root, &bundled_root) + .expect("package-backed details"); + + assert!( + !details.enabled, + "disabled state comes from the install record" + ); + assert_eq!(details.status, "accepted"); + assert!(details.localized_descriptions.is_empty()); + assert_eq!( + details.permissions, + ["languageServer.launch", "process.run", "process.test"] + ); + assert_eq!( + details.programs.len(), + 4, + "Core returns every complete program" + ); + assert!(details.programs.iter().all(Value::is_object)); + assert_eq!(details.versions.current.channel, "disk"); + assert_eq!( + details.versions.current.archive_sha256.as_deref(), + Some("archive-current") + ); + assert_eq!( + details.versions.current.verification.status, + "approvedDigest" + ); + let previous = details.versions.previous.expect("previous provenance"); + assert_eq!(previous.version, "0.0.9"); + assert_eq!(previous.channel, "marketplace"); + assert_eq!(previous.source_name.as_deref(), Some("Lithe Store")); + assert_eq!(previous.verification.status, "unknown"); + assert_eq!(details.versions.update_status, "unknown"); + assert!(details.operations.can_rollback); + assert!(details.features.iter().any(|feature| { + feature.kind == "languageServer" && feature.availability == "available" + })); + assert!(details + .features + .iter() + .any(|feature| { feature.kind == "run" && feature.availability == "declaredOnly" })); +} + +#[test] +fn bundled_details_report_the_verified_host_seal_not_a_manifest_claim() { + let root = TestDirectory::new("bundled"); + let bundled_root = root.0.join("bundled"); + let install_root = root.0.join("installed"); + let mut manifest = fixture(); + manifest["id"] = json!("dev.lithe.plugin.php-support"); + manifest["vendor"]["signatureRequirement"] = json!("sameTeamAsHost"); + let package_root = bundled_root.join("php").join("0.1.0"); + std::fs::create_dir_all(&package_root).expect("bundled package"); + std::fs::write( + package_root.join("plugin.json"), + serde_json::to_vec_pretty(&manifest).expect("manifest bytes"), + ) + .expect("manifest file"); + + let packages = scan::build_catalog_from(&bundled_root, &install_root).expect("catalog"); + let package = find_package(&packages, "dev.lithe.plugin.php-support"); + let details = + details::build_details(package, &install_root, &bundled_root).expect("bundled details"); + assert_eq!(details.versions.current.origin, "bundled"); + assert_eq!(details.versions.current.verification.status, "hostSeal"); + assert!(!details.operations.can_uninstall); +} + +#[test] +fn invalid_installation_record_remains_explainable_and_quarantined() { + let root = TestDirectory::new("quarantine"); + let bundled_root = root.0.join("bundled"); + let install_root = root.0.join("installed"); + std::fs::create_dir_all(&bundled_root).expect("bundled root"); + let manifest = community_manifest(); + let plugin_id = manifest["id"].as_str().expect("plugin ID"); + let package_root = install_root.join(plugin_id).join("0.1.0"); + std::fs::create_dir_all(&package_root).expect("package root"); + std::fs::write( + package_root.join("plugin.json"), + serde_json::to_vec_pretty(&manifest).expect("manifest bytes"), + ) + .expect("manifest file"); + std::fs::write( + details::installation_record_path(&install_root, plugin_id), + json!({ + "schemaVersion": 1, + "pluginId": plugin_id, + "version": "0.1.0", + "enabled": true, + }) + .to_string(), + ) + .expect("legacy record"); + + let packages = scan::build_catalog_from(&bundled_root, &install_root).expect("catalog"); + let package = find_package(&packages, plugin_id); + let details = details::build_details(package, &install_root, &bundled_root) + .expect("quarantine details are readable"); + assert_eq!(details.status, "rejected"); + assert_eq!( + details.rejection.expect("rejection").code, + "plugin.manifest.invalid" + ); + assert_eq!(details.versions.current.verification.status, "rejected"); + assert!(!details.operations.can_set_enabled); + assert!(details.operations.can_uninstall); +} + +#[test] +fn detail_read_refuses_digest_changes_after_the_catalog_snapshot() { + let root = TestDirectory::new("changed"); + let bundled_root = root.0.join("bundled"); + let install_root = root.0.join("installed"); + std::fs::create_dir_all(&bundled_root).expect("bundled root"); + let manifest = community_manifest(); + let package_root = write_installed_package(&install_root, &manifest, true); + let packages = scan::build_catalog_from(&bundled_root, &install_root).expect("catalog"); + let package = find_package(&packages, "io.example.details-fixture"); + std::fs::write(package_root.join("changed-after-scan.txt"), b"changed") + .expect("post-scan mutation"); + + let error: PluginDetailsError = details::build_details(package, &install_root, &bundled_root) + .expect_err("changed digest is refused"); + assert_eq!(error.code, "plugin.details.packageChanged"); +} + +#[test] +fn detail_read_refuses_an_installation_record_that_no_longer_owns_the_scanned_package() { + let root = TestDirectory::new("record-changed"); + let bundled_root = root.0.join("bundled"); + let install_root = root.0.join("installed"); + std::fs::create_dir_all(&bundled_root).expect("bundled root"); + let manifest = community_manifest(); + let plugin_id = manifest["id"].as_str().expect("plugin ID"); + write_installed_package(&install_root, &manifest, true); + let packages = scan::build_catalog_from(&bundled_root, &install_root).expect("catalog"); + let package = find_package(&packages, plugin_id); + let record_path = details::installation_record_path(&install_root, plugin_id); + let original: Value = + serde_json::from_slice(&std::fs::read(&record_path).expect("record bytes")) + .expect("record JSON"); + + for (field, changed) in [ + ("schemaVersion", json!(1)), + ("pluginId", json!("io.example.different")), + ("version", json!("9.9.9")), + ("origin", json!("marketplace")), + ( + "disk", + json!({ "fileName": "changed.zip", "contentDigest": "changed", "approvedPrograms": [] }), + ), + ] { + let mut record = original.clone(); + record[field] = changed; + std::fs::write( + &record_path, + serde_json::to_vec(&record).expect("record bytes"), + ) + .expect("changed record"); + let error = details::build_details(package, &install_root, &bundled_root) + .expect_err("stale provenance must not be attached to another active package"); + assert_eq!( + error.code, "plugin.details.installationRecordChanged", + "changed {field}" + ); + } +} + +#[test] +fn detail_enabled_state_comes_from_the_live_installation_record() { + let root = TestDirectory::new("enabled-changed"); + let bundled_root = root.0.join("bundled"); + let install_root = root.0.join("installed"); + std::fs::create_dir_all(&bundled_root).expect("bundled root"); + let manifest = community_manifest(); + let plugin_id = manifest["id"].as_str().expect("plugin ID"); + write_installed_package(&install_root, &manifest, true); + let packages = scan::build_catalog_from(&bundled_root, &install_root).expect("catalog"); + let package = find_package(&packages, plugin_id); + write_installed_package(&install_root, &manifest, false); + + let details = details::build_details(package, &install_root, &bundled_root) + .expect("same package with changed enabled state"); + assert!(!details.enabled); +} + +#[test] +fn details_reject_a_cached_package_outside_the_owning_store() { + let root = TestDirectory::new("boundary"); + let bundled_root = root.0.join("bundled"); + let install_root = root.0.join("installed"); + let outside_root = root.0.join("outside"); + std::fs::create_dir_all(&bundled_root).expect("bundled root"); + std::fs::create_dir_all(&install_root).expect("installed root"); + let manifest = community_manifest(); + write_installed_package(&outside_root, &manifest, true); + let packages = scan::build_catalog_from(&bundled_root, &outside_root).expect("catalog"); + let package = find_package(&packages, "io.example.details-fixture"); + + let error = details::build_details(package, &install_root, &bundled_root) + .expect_err("foreign store path is refused"); + assert_eq!(error.code, "plugin.details.pathBoundary"); +} + +#[test] +fn provenance_origin_is_not_inferred_from_manifest_vendor_fields() { + let root = TestDirectory::new("origin"); + let bundled_root = root.0.join("bundled"); + let install_root = root.0.join("installed"); + std::fs::create_dir_all(&bundled_root).expect("bundled root"); + let mut manifest = community_manifest(); + manifest["vendor"]["id"] = json!("dev.lithe"); + manifest["vendor"]["displayName"] = json!("Claims Official"); + write_installed_package(&install_root, &manifest, true); + + let packages = scan::build_catalog_from(&bundled_root, &install_root).expect("catalog"); + let package = find_package(&packages, "io.example.details-fixture"); + let details = + details::build_details(package, &install_root, &bundled_root).expect("community details"); + assert_eq!(details.versions.current.origin, "community"); + assert_eq!(details.versions.current.channel, "disk"); + assert_eq!( + details.versions.current.verification.status, + "approvedDigest" + ); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/functions/mod.rs b/windows/tauri/src-tauri/src/plugin_host/functions/mod.rs new file mode 100644 index 0000000000..418452afba --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/functions/mod.rs @@ -0,0 +1,748 @@ +//! Consent-bound settings actions. Core owns negotiation, JSON-RPC, function +//! references, progress and cancellation. Every action has an isolated Core +//! process owner, never an editor/build binding that another consumer owns. +pub mod native; +mod runtime; +#[cfg(test)] +mod tests; +pub mod web; + +use super::{launch, settings, store}; +pub use runtime::Snapshot as FunctionSnapshot; +use runtime::{Operation, Phase}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Manager, WebviewWindow}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Error { + pub code: String, + pub message: String, + pub plugin_error_code: Option, +} +impl Error { + pub(super) fn new(code: &str) -> Self { + Self { + code: code.into(), + message: code.into(), + plugin_error_code: None, + } + } + pub(super) fn core(value: &Value) -> Self { + Self { + code: value["details"] + .as_str() + .filter(|code| { + matches!( + *code, + "plugin.function.invalidReference" + | "plugin.function.invalidParams" + | "plugin.function.unavailable" + | "plugin.function.wrongPlugin" + | "plugin.function.unknown" + ) + }) + .or_else(|| value["code"].as_str()) + .unwrap_or("plugin.function.failed") + .into(), + message: value["message"] + .as_str() + .unwrap_or("plugin.function.failed") + .chars() + .take(1024) + .collect(), + plugin_error_code: value["pluginErrorCode"].as_str().map(str::to_owned), + } + } +} +pub(super) type Reply = Result; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Context { + pub window: String, + pub window_incarnation: u64, + pub workspace_path: String, + pub generation: u64, + pub plugin_id: String, + pub package_identity: String, + pub setting_identity: String, + pub action_id: String, + pub function_id: String, + pub operation_id: String, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Review { + pub context: Context, + pub title: String, + pub confirm: bool, + pub executes_project_code: bool, + pub may_use_network: bool, + pub launch_plan: Value, + pub program: launch::ResolvedLaunch, +} + +#[derive(Default)] +pub struct FunctionsState { + native: native::Panels, + operations: Arc>>>, + retired: Arc>>, +} +impl FunctionsState { + fn retire_window(&self, owner: &Arc) -> Vec> { + owner.alive.store(false, Ordering::Release); + self.native.revoke(|binding| binding.owner.id == owner.id); + let entries: Vec<_> = self + .operations + .lock() + .expect("function operations") + .values() + .filter(|entry| entry.owner.id == owner.id) + .cloned() + .collect(); + for entry in &entries { + entry.cancel(); + } + self.retired + .lock() + .expect("retired function operations") + .retain(|_, receipt| receipt.incarnation != owner.id); + entries + } +} + +#[derive(Clone)] +struct ReceiptOwner { + window: String, + incarnation: u64, +} +pub(super) struct WindowOwner { + id: u64, + alive: AtomicBool, +} +impl tauri::Resource for WindowOwner {} +static NEXT_WINDOW_OWNER: AtomicU64 = AtomicU64::new(1); + +fn native_window_owner(window: &tauri::Window) -> Arc { + table_window_owner(&mut window.resources_table()) +} +fn table_window_owner(table: &mut tauri::ResourceTable) -> Arc { + let existing = table + .names() + .find_map(|(id, _)| table.get::(id).ok()); + existing.unwrap_or_else(|| { + let owner = Arc::new(WindowOwner { + id: NEXT_WINDOW_OWNER.fetch_add(1, Ordering::Relaxed), + alive: AtomicBool::new(true), + }); + table.add_arc(owner.clone()); + owner + }) +} + +fn caller_operation(app: &AppHandle, window: &WebviewWindow, id: &str) -> Reply> { + let owner = native_window_owner(&window.as_ref().window()); + let entry = operation(app, window.label(), id)?; + if !owns_operation(&owner, &entry) { + return Err(Error::new("plugin.function.staleContext")); + } + Ok(entry) +} +fn owns_operation(owner: &WindowOwner, entry: &Operation) -> bool { + owner.id == entry.review.context.window_incarnation && owner.alive.load(Ordering::Acquire) +} +fn check_expected_owner(owner: &WindowOwner, expected: Option) -> Reply<()> { + if !owner.alive.load(Ordering::Acquire) || expected.is_some_and(|id| id != owner.id) { + return Err(Error::new("plugin.function.staleContext")); + } + Ok(()) +} + +fn generation(app: &AppHandle, window: &str, root: &str) -> Reply { + if !(window == "main" || window.starts_with("workspace-")) + || app.get_webview_window(window).is_none() + { + return Err(Error::new("plugin.function.staleContext")); + } + app.state::() + .function_generation(window, root) + .map_err(|_| Error::new("plugin.function.staleContext")) +} + +fn check(app: &AppHandle, operation: &Operation) -> Reply<()> { + check_context(app, operation)?; + check_snapshot(app, operation) +} +fn check_snapshot(app: &AppHandle, operation: &Operation) -> Reply<()> { + let snapshot = settings::read_snapshot( + app, + &operation.review.context.plugin_id, + &operation.review.context.workspace_path, + ) + .map_err(|_| Error::new("plugin.function.staleContext"))?; + if snapshot.package_identity != operation.review.context.package_identity + || snapshot.identity != operation.review.context.setting_identity + { + return Err(Error::new("plugin.function.staleContext")); + } + Ok(()) +} +fn check_context(app: &AppHandle, operation: &Operation) -> Reply<()> { + if operation.cancelled() || !operation.owner.alive.load(Ordering::Acquire) { + return Err(Error::new("plugin.function.staleContext")); + } + check_scope(app, operation)?; + native::check(app, operation) +} +fn check_scope(app: &AppHandle, operation: &Operation) -> Reply<()> { + if app + .state::() + .blocked(&operation.review.context.plugin_id) + || generation( + app, + &operation.review.context.window, + &operation.review.context.workspace_path, + )? != operation.review.context.generation + || app + .state::() + .function_scope_revision( + &operation.review.context.window, + &operation.review.context.workspace_path, + &operation.review.context.plugin_id, + ) + .map_or(true, |revision| revision != operation.scope_revision) + { + return Err(Error::new("plugin.function.staleContext")); + } + Ok(()) +} + +/// Only a declaration from the trusted normalized manifest selects the +/// function and its provider. Language-shared providers stay unavailable: +/// their language owner may be in use by an editor or build operation. +fn selection(manifest: &Value, action_id: &str) -> Reply<(Value, String)> { + if let Some(spec) = action_id.strip_prefix("__web__:") { + let (build_system, function) = spec + .split_once(':') + .filter(|(build_system, _)| !build_system.is_empty()) + .ok_or_else(|| Error::new("plugin.function.invalidReference"))?; + let system = manifest["buildSystems"] + .as_array() + .into_iter() + .flatten() + .find(|system| system["id"] == build_system) + .ok_or_else(|| Error::new("plugin.function.providerUnavailable"))?; + if !system["provider"].is_object() || system["provider"]["languageServer"].is_string() { + return Err(Error::new("plugin.function.providerUnavailable")); + } + return Ok(( + json!({"id": action_id, "function": function, "title": function, "confirm": true}), + build_system.into(), + )); + } + let action = manifest["settingActions"] + .as_array() + .into_iter() + .flatten() + .find(|action| action["id"] == action_id) + .ok_or_else(|| Error::new("plugin.function.unknownAction"))? + .clone(); + let target = action["page"]["buildSystemID"].as_str(); + let language = action["page"]["languageID"].as_str(); + let providers: Vec<_> = manifest["buildSystems"] + .as_array() + .into_iter() + .flatten() + .filter(|system| { + system["provider"].is_object() + && target.is_none_or(|id| system["id"] == id) + && language.is_none_or(|id| system["provider"]["languageServer"] == id) + }) + .collect(); + if providers.len() != 1 || providers[0]["provider"]["languageServer"].is_string() { + return Err(Error::new("plugin.function.providerUnavailable")); + } + Ok(( + action, + providers[0]["id"] + .as_str() + .ok_or_else(|| Error::new("plugin.function.providerUnavailable"))? + .into(), + )) +} + +/// Prepare a page-owned function using the same R10 operation owner. The page +/// label is resolved from the native page registry; plugin, package, workspace +/// and build instance are never accepted from page JSON as authority. +#[tauri::command] +pub async fn declarative_plugins_web_function_prepare( + app: AppHandle, + window: WebviewWindow, + page_label: String, + instance: String, + function_id: String, + operation_id: String, + params: Value, + list: bool, +) -> Reply { + if !app + .state::() + .functions + .active(&operation_id, &page_label) + { + return Err(Error::new("requestCancelled")); + } + let page = super::web::page_identity(&app, &page_label) + .map_err(|_| Error::new("plugin.web.forbidden"))?; + if page.host_label != window.label() { + return Err(Error::new("plugin.web.forbidden")); + } + let build_context = super::build::run_build_task(app.clone(), { + let page = page.clone(); + let instance = instance.clone(); + move |registry, source, _| { + let (view, contexts) = super::web::build::checked_view(registry, source, &page) + .map_err(|_| "plugin.web.forbidden".to_string())?; + super::web::build::selected_context(&view, &contexts, &instance) + .map(|(_, context)| context) + .map_err(|error| error) + } + }) + .await + .map_err(|_| Error::new("plugin.web.forbidden"))?; + if build_context.plugin_id != page.plugin_id + || build_context.workspace_root != page.workspace_path + || (!list + && !function_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) + || function_id.len() > 128) + { + return Err(Error::new("plugin.function.invalidReference")); + } + let snapshot = settings::read_snapshot(&app, &page.plugin_id, &page.workspace_path) + .map_err(|_| Error::new("plugin.function.unavailable"))?; + let action_id = format!("__web__:{}:{}", build_context.build_system_id, function_id); + let cancel_window = window.clone(); + let review = declarative_plugins_function_prepare( + app.clone(), + window, + page.plugin_id.clone(), + action_id, + page.workspace_path.clone(), + snapshot.identity, + operation_id.clone(), + ) + .await?; + if !app + .state::() + .functions + .active(&operation_id, &page_label) + { + let _ = declarative_plugins_function_cancel( + app.clone(), + cancel_window, + operation_id.clone(), + None, + None, + ) + .await; + return Err(Error::new("requestCancelled")); + } + let entry = operation(&app, &page.host_label, &operation_id)?; + entry.set_params(params); + Ok(review) +} + +fn operation(app: &AppHandle, window: &str, id: &str) -> Reply> { + app.state::() + .operations + .lock() + .map_err(|_| Error::new("plugin.function.unavailable"))? + .get(id) + .filter(|operation| operation.review.context.window == window) + .cloned() + .ok_or_else(|| Error::new("plugin.function.staleContext")) +} + +#[tauri::command] +pub async fn declarative_plugins_function_prepare( + app: AppHandle, + window: WebviewWindow, + plugin_id: String, + action_id: String, + workspace_path: String, + expected_identity: String, + operation_id: String, +) -> Reply { + prepare_owned( + app, + window, + plugin_id, + action_id, + workspace_path, + expected_identity, + operation_id, + None, + ) + .await +} + +async fn prepare_owned( + app: AppHandle, + window: WebviewWindow, + plugin_id: String, + action_id: String, + workspace_path: String, + expected_identity: String, + operation_id: String, + native: Option, +) -> Reply { + if operation_id.len() > 128 + || operation_id.is_empty() + || !operation_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err(Error::new("plugin.function.invalidReference")); + } + let label = window.label().to_string(); + let owner = native_window_owner(&window.as_ref().window()); + // Capture before the blocking lookup. A scoped reload may finish while + // that worker is still preparing; the old revision must stay revoked. + let scope_revision = app + .state::() + .function_scope_revision(&label, &workspace_path, &plugin_id) + .map_err(|_| Error::new("plugin.function.staleContext"))?; + tauri::async_runtime::spawn_blocking(move || { + let generation = generation(&app, &label, &workspace_path)?; + let lease = app + .state::() + .acquire(&plugin_id) + .map_err(|_| Error::new("plugin.function.staleContext"))?; + let snapshot = settings::read_snapshot(&app, &plugin_id, &workspace_path) + .map_err(|_| Error::new("plugin.function.unavailable"))?; + if snapshot.identity != expected_identity { + return Err(Error::new("plugin.settings.conflict")); + } + let (action, build) = if let Some(call) = &native { + call.selection(&snapshot.manifest)? + } else { + selection(&snapshot.manifest, &action_id)? + }; + let package = launch::verified_package_by_id(&app, &plugin_id) + .map_err(|_| Error::new("plugin.function.staleContext"))?; + let directory = store::plugin_state_directory(&app, &plugin_id) + .map_err(|_| Error::new("plugin.function.unavailable"))?; + let layers = settings::load_layers(&app, &plugin_id, &workspace_path) + .map_err(|_| Error::new("plugin.function.unavailable"))?; + let plan = super::scan::core_execute( + "plugins.declarative.languageServerLaunchPlan", + json!({ + "manifest": package.manifest, "buildSystemID": build, + "request": launch::launch_request(&directory, &package, &workspace_path, layers), + }), + ) + .map_err(|_| Error::new("plugin.function.providerUnavailable"))?; + if plan["status"] != "ok" { + return Err(Error::new("plugin.function.providerUnavailable")); + } + let plan = plan["plan"].clone(); + let program = launch::resolve_launch_passive(&package, &build, &plan, &directory) + .map_err(|_| Error::new("plugin.function.providerUnavailable"))?; + let review = Review { + context: Context { + window: label, + window_incarnation: owner.id, + workspace_path, + generation, + plugin_id, + package_identity: snapshot.package_identity, + setting_identity: snapshot.identity, + action_id, + function_id: action["function"] + .as_str() + .ok_or_else(|| Error::new("plugin.function.invalidReference"))? + .into(), + operation_id: operation_id.clone(), + }, + title: action["title"].as_str().unwrap_or_default().into(), + confirm: action["confirm"] == true, + executes_project_code: plan["buildProvider"]["executesProjectCode"] + .as_bool() + .unwrap_or(true), + may_use_network: plan["buildProvider"]["mayUseNetwork"] + .as_bool() + .unwrap_or(true), + launch_plan: plan, + program, + }; + let mut operation = Operation::new(review.clone(), directory, lease, owner); + operation.scope_revision = scope_revision; + let entry = Arc::new(operation); + if let Some(call) = native { + entry.set_params(call.params.clone()); + *entry.native.lock().expect("native function call") = Some(call); + } + check(&app, &entry)?; + let state = app.state::(); + entry.bind_registry(&state.operations, &state.retired); + let mut entries = state + .operations + .lock() + .map_err(|_| Error::new("plugin.function.unavailable"))?; + // Revocation takes this same registry before its close snapshot. A + // late review is either visible to it or refused before publication. + check_context(&app, &entry)?; + let retired = state + .retired + .lock() + .map_err(|_| Error::new("plugin.function.unavailable"))?; + if entries.len() >= 64 + || retired.len() >= 4096 + || retired.contains_key(&operation_id) + || entries.contains_key(&operation_id) + { + return Err(Error::new("plugin.function.busy")); + } + drop(retired); + entries.insert(operation_id, entry.clone()); + drop(entries); + // Abandoned passive reviews cannot retain a package lease forever. + let expiry_app = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(120)).await; + if entry.phase() == Phase::Review { + entry.cancel(); + let _ = runtime::cleanup(&entry).await; + remove_clean(&expiry_app, &entry); + } + }); + Ok(review) + }) + .await + .map_err(|_| Error::new("plugin.function.unavailable"))? +} + +fn remove_clean(app: &AppHandle, entry: &Arc) { + if entry.clean() { + let state = app.state::(); + let mut entries = state.operations.lock().expect("function operations"); + if entries + .get(&entry.review.context.operation_id) + .is_some_and(|current| Arc::ptr_eq(current, entry)) + { + if !entry.review.context.action_id.starts_with("__web__:") { + let mut retired = state.retired.lock().expect("retired function operations"); + if entry.owner.alive.load(Ordering::Acquire) { + retired.insert( + entry.review.context.operation_id.clone(), + ReceiptOwner { + window: entry.review.context.window.clone(), + incarnation: entry.owner.id, + }, + ); + } + } + entries.remove(&entry.review.context.operation_id); + native::release(entry); + } + } +} + +#[tauri::command] +pub async fn declarative_plugins_function_confirm( + app: AppHandle, + window: WebviewWindow, + operation_id: String, +) -> Reply<()> { + let entry = caller_operation(&app, &window, &operation_id)?; + let verify_app = app.clone(); + let verify = entry.clone(); + tauri::async_runtime::spawn_blocking(move || check(&verify_app, &verify)) + .await + .map_err(|_| Error::new("plugin.function.unavailable"))??; + if entry.review.context.action_id.starts_with("__web__:") { + web::confirm(&app, &entry, window.label())?; + } else { + native::begin(&app, &entry)?; + } + tauri::async_runtime::spawn(runtime::run(app, entry)); + Ok(()) +} + +#[tauri::command] +pub async fn declarative_plugins_function_state( + app: AppHandle, + window: WebviewWindow, + operation_id: String, + cleanup_only: Option, + expected_window_incarnation: Option, +) -> Reply { + let owner = native_window_owner(&window.as_ref().window()); + check_expected_owner(&owner, expected_window_incarnation)?; + let entry = caller_operation(&app, &window, &operation_id)?; + // A retained cleanup receipt outlives its workspace. This read is still + // authenticated by the caller's exact native window resource above. + if cleanup_only == Some(true) { + let mut snapshot = entry.snapshot(); + snapshot.progress = None; + snapshot.result = None; + return Ok(snapshot); + } + tauri::async_runtime::spawn_blocking(move || { + check_scope(&app, &entry)?; + native::check(&app, &entry)?; + if matches!(entry.phase(), Phase::Succeeded | Phase::Failed) { + check_snapshot(&app, &entry)?; + } + Ok(entry.snapshot()) + }) + .await + .map_err(|_| Error::new("plugin.function.unavailable"))? +} + +#[tauri::command] +pub async fn declarative_plugins_function_cancel( + app: AppHandle, + window: WebviewWindow, + operation_id: String, + web_error: Option, + expected_window_incarnation: Option, +) -> Reply<()> { + let owner = native_window_owner(&window.as_ref().window()); + check_expected_owner(&owner, expected_window_incarnation)?; + // A refusal can precede native prepare. Revoke the page receipt first so + // late preparation cannot publish or start a provider after cancellation. + let page_owned = app + .state::() + .functions + .cancel_for_host(&operation_id, window.label(), web_error); + // A completed receipt may already have been released by the same caller. + let entry = match caller_operation(&app, &window, &operation_id) { + Ok(entry) => entry, + Err(error) => { + if page_owned + || app + .state::() + .retired + .lock() + .map_err(|_| Error::new("plugin.function.unavailable"))? + .get(&operation_id) + .is_some_and(|receipt| { + receipt.window == window.label() + && receipt.incarnation == owner.id + && owner.alive.load(Ordering::Acquire) + }) + { + return Ok(()); + } + return Err(error); + } + }; + entry.cancel(); + runtime::cleanup(&entry).await?; + remove_clean(&app, &entry); + Ok(()) +} + +async fn close_matching( + app: &AppHandle, + predicate: impl Fn(&Context) -> bool, +) -> Result<(), String> { + drain_matching(&app.state::(), predicate).await +} + +async fn drain_matching( + state: &FunctionsState, + predicate: impl Fn(&Context) -> bool, +) -> Result<(), String> { + let entries: Vec<_> = state + .operations + .lock() + .map_err(|_| "plugin.function.unavailable")? + .values() + .filter(|entry| predicate(&entry.review.context)) + .cloned() + .collect(); + for entry in &entries { + entry.cancel(); + } + let tasks: Vec<_> = entries + .into_iter() + .map(|entry| { + tauri::async_runtime::spawn(async move { + runtime::cleanup(&entry).await?; + entry.retire_clean(); + Ok::<(), Error>(()) + }) + }) + .collect(); + let mut error = None; + for task in tasks { + match task.await { + Ok(Ok(())) => {} + Ok(Err(failure)) => error = Some(failure.code), + Err(_) => error = Some("plugin.function.cleanupFailed".into()), + } + } + error.map_or(Ok(()), Err) +} +pub async fn close_plugin(app: &AppHandle, plugin: &str) -> Result<(), String> { + app.state::() + .native + .revoke(|binding| binding.plugin_id == plugin); + close_matching(app, |context| context.plugin_id == plugin).await +} +/// Retire the exact native window before any await. Label reuse cannot change +/// this resource or make its delayed cleanup select a replacement window. +pub fn window_destroyed(window: &tauri::Window) { + let owner = native_window_owner(window); + let app = window.app_handle().clone(); + let entries = app.state::().retire_window(&owner); + tauri::async_runtime::spawn(async move { + for entry in entries { + if let Err(error) = runtime::cleanup(&entry).await { + eprintln!("Could not release plugin function owner: {}", error.code); + } else { + remove_clean(&app, &entry); + } + } + }); +} +pub async fn close_workspace(app: &AppHandle, window: &str, root: &str) -> Result<(), String> { + app.state::() + .native + .revoke(|binding| binding.window == window && binding.workspace == root); + close_matching(app, |context| { + context.window == window && context.workspace_path == root + }) + .await +} + +/// A project settings reload retires only this plugin in this workspace. +/// The build registry has already raised its scoped admission revision before +/// this snapshot; cleanup failures retain the original owner for retry. +pub(crate) async fn close_scope( + app: &AppHandle, + window: &str, + root: &str, + plugin: &str, +) -> Result<(), String> { + app.state::() + .functions + .revoke_scope(window, root, plugin); + app.state::().native.revoke(|binding| { + binding.window == window && binding.workspace == root && binding.plugin_id == plugin + }); + close_matching(app, |context| { + context.window == window && context.workspace_path == root && context.plugin_id == plugin + }) + .await +} diff --git a/windows/tauri/src-tauri/src/plugin_host/functions/native.rs b/windows/tauri/src-tauri/src/plugin_host/functions/native.rs new file mode 100644 index 0000000000..efa0613a28 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/functions/native.rs @@ -0,0 +1,491 @@ +//! Declaration-bound native panels reuse the settings/web function owner. +//! A renderer supplies values, never a function, provider, program or permission. +use super::super::build_registry::BuildModelContext; +use super::*; +use serde_json::Map; + +#[derive(Default)] +pub(super) struct Panels(Mutex>>); +pub(super) struct Binding { + id: String, + registration: u64, + pub(super) window: String, + pub(super) workspace: String, + pub(super) plugin_id: String, + tool_window: String, + ui: Value, + package_identity: String, + setting_identity: String, + pub(super) owner: Arc, + context: BuildModelContext, + alive: AtomicBool, + busy: AtomicBool, +} +impl Panels { + pub(super) fn revoke(&self, predicate: impl Fn(&Binding) -> bool) { + self.0.lock().expect("native panels").retain(|_, binding| { + if predicate(binding) { + binding.alive.store(false, Ordering::Release); + false + } else { + true + } + }); + } + fn get(&self, id: &str, owner: &WindowOwner) -> Reply> { + self.0 + .lock() + .expect("native panels") + .get(id) + .filter(|binding| binding.owner.id == owner.id && binding.alive.load(Ordering::Acquire)) + .cloned() + .ok_or_else(|| Error::new("plugin.function.staleContext")) + } + fn insert( + &self, + binding: Arc, + check: impl FnOnce(&Binding) -> Reply<()>, + ) -> Reply<()> { + let mut panels = self.0.lock().expect("native panels"); + check(&binding)?; + let same_slot = |old: &Binding| { + old.owner.id == binding.owner.id + && old.plugin_id == binding.plugin_id + && old.tool_window == binding.tool_window + && old.workspace == binding.workspace + }; + if panels + .values() + .any(|old| same_slot(old) && old.registration > binding.registration) + { + return Err(Error::new("plugin.function.staleContext")); + } + // Replace this precise slot before returning its new handle. A late + // close uses its old token and cannot revoke the replacement. + panels.retain(|_, old| { + if same_slot(old) { + old.alive.store(false, Ordering::Release); + false + } else { + true + } + }); + if panels.len() >= 128 { + return Err(Error::new("plugin.function.busy")); + } + panels.insert(binding.id.clone(), binding); + Ok(()) + } +} + +struct Permit { + binding: Arc, + released: AtomicBool, +} +impl Permit { + fn release(&self) { + if !self.released.swap(true, Ordering::AcqRel) { + self.binding.busy.store(false, Ordering::Release); + } + } +} +impl Drop for Permit { + fn drop(&mut self) { + self.release(); + } +} +pub(super) struct Call { + binding: Arc, + _permit: Permit, + action: Option, + ui: Value, + pub(super) params: Value, +} +impl Call { + pub(super) fn selection(&self, manifest: &Value) -> Reply<(Value, String)> { + let ui = declaration(manifest, &self.binding.tool_window)?; + if ui != self.binding.ui { + return Err(Error::new("plugin.function.staleContext")); + } + selection(&ui, self.action.as_deref()) + } +} + +fn declaration(manifest: &Value, tool_window: &str) -> Reply { + manifest["ui"]["toolWindows"] + .as_array() + .into_iter() + .flatten() + .find(|window| window["id"] == tool_window) + .and_then(|window| window.get("native")) + .filter(|ui| ui.is_object()) + .cloned() + .ok_or_else(|| Error::new("plugin.function.unavailable")) +} +fn selection(ui: &Value, action: Option<&str>) -> Reply<(Value, String)> { + let build = ui["source"]["buildSystemID"] + .as_str() + .ok_or_else(|| Error::new("plugin.function.providerUnavailable"))?; + let (function, title) = if let Some(id) = action { + let action = ui["actions"] + .as_array() + .into_iter() + .flatten() + .find(|action| action["id"] == id && action["kind"] == "invoke") + .ok_or_else(|| Error::new("plugin.function.unknownAction"))?; + (action["function"].as_str(), action["title"].as_str()) + } else { + (ui["source"]["function"].as_str(), ui["id"].as_str()) + }; + Ok(( + json!({"function":function.ok_or_else(|| Error::new("plugin.function.invalidReference"))?, + "title":title.unwrap_or_default(),"confirm":true}), + build.into(), + )) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PanelReceipt { + binding_id: String, + setting_identity: String, + package_identity: String, +} + +/// Register only an accepted native contribution and its unique detected +/// instance. All identity inputs are lookup hints checked against native facts. +#[tauri::command] +pub async fn declarative_plugins_native_function_open( + app: AppHandle, + window: WebviewWindow, + plugin_id: String, + window_id: String, + workspace_path: String, + expected_package_identity: String, +) -> Reply { + // Capture request order before any await: a slow old mount must not + // replace a newer registration that has already become usable. + static NEXT: AtomicU64 = AtomicU64::new(1); + let registration = NEXT.fetch_add(1, Ordering::Relaxed); + let owner = native_window_owner(&window.as_ref().window()); + let host = window.label().to_owned(); + let identity = expected_package_identity; + let (ui, snapshot) = tauri::async_runtime::spawn_blocking({ + let app = app.clone(); + let plugin = plugin_id.clone(); + let tool = window_id.clone(); + let root = workspace_path.clone(); + move || { + let catalog = super::super::native_ui::catalog(&app) + .map_err(|_| Error::new("plugin.function.unavailable"))?; + let entry = catalog + .iter() + .find(|entry| { + entry["pluginId"] == plugin + && entry["windowId"] == tool + && entry["packageIdentity"] == identity + }) + .ok_or_else(|| Error::new("plugin.function.staleContext"))?; + let snapshot = settings::read_snapshot(&app, &plugin, &root) + .map_err(|_| Error::new("plugin.function.unavailable"))?; + let ui = declaration(&snapshot.manifest, &tool)?; + if ui != entry["native"] { + return Err(Error::new("plugin.function.staleContext")); + } + if !super::super::native_ui::catalog(&app) + .map_err(|_| Error::new("plugin.function.staleContext"))? + .iter() + .any(|entry| { + entry["pluginId"] == plugin + && entry["windowId"] == tool + && entry["packageIdentity"] == identity + }) + { + return Err(Error::new("plugin.function.staleContext")); + } + let (_, build) = selection(&ui, None)?; + let system = snapshot.manifest["buildSystems"] + .as_array() + .into_iter() + .flatten() + .find(|system| system["id"] == build) + .ok_or_else(|| Error::new("plugin.function.providerUnavailable"))?; + if !system["provider"].is_object() || system["provider"]["languageServer"].is_string() { + return Err(Error::new("plugin.function.providerUnavailable")); + } + Ok((ui, snapshot)) + } + }) + .await + .map_err(|_| Error::new("plugin.function.unavailable"))??; + let system = ui["source"]["buildSystemID"] + .as_str() + .unwrap_or_default() + .to_owned(); + let context = super::super::build::run_build_task(app.clone(), { + let host = host.clone(); + let root = workspace_path.clone(); + let plugin = plugin_id.clone(); + move |registry, source, _| { + let view = registry.view(&host); + registry.check_read_snapshot(&host, &root, view.generation)?; + let instance_root = unique_root(&view.instances, &plugin, &system)?; + let context = registry.model_context(&host, &root, &plugin, &system, instance_root)?; + if source.package(&plugin).as_ref() != Some(&context.package) { + return Err("plugin.function.staleContext".into()); + } + Ok(context) + } + }) + .await + .map_err(|_| Error::new("plugin.function.providerUnavailable"))?; + let binding = Arc::new(Binding { + id: format!("native-{registration}"), + registration, + window: host, + workspace: workspace_path, + plugin_id, + tool_window: window_id, + ui, + package_identity: snapshot.package_identity, + setting_identity: snapshot.identity, + owner, + context, + alive: AtomicBool::new(true), + busy: AtomicBool::new(false), + }); + check_binding(&app, &binding)?; + let receipt = PanelReceipt { + binding_id: binding.id.clone(), + setting_identity: binding.setting_identity.clone(), + package_identity: binding.package_identity.clone(), + }; + app.state::() + .native + .insert(binding, |binding| check_binding(&app, binding))?; + Ok(receipt) +} + +fn unique_root<'a>(instances: &'a [Value], plugin: &str, system: &str) -> Result<&'a str, String> { + let mut rows = instances + .iter() + .filter(|row| row["pluginID"] == plugin && row["buildSystemID"] == system); + let root = rows + .next() + .and_then(|row| row["root"].as_str()) + .ok_or("plugin.function.providerUnavailable")?; + if rows.next().is_some() { + return Err("plugin.function.providerUnavailable".into()); + } + Ok(root) +} + +fn check_binding(app: &AppHandle, binding: &Binding) -> Reply<()> { + if !binding.alive.load(Ordering::Acquire) + || !binding.owner.alive.load(Ordering::Acquire) + || app + .state::() + .blocked(&binding.plugin_id) + || !app + .state::() + .function_context_is_current(&binding.context) + { + return Err(Error::new("plugin.function.staleContext")); + } + Ok(()) +} +pub(super) fn check(app: &AppHandle, entry: &Operation) -> Reply<()> { + if let Some(call) = entry.native.lock().expect("native function call").as_ref() { + check_binding(app, &call.binding)?; + } + Ok(()) +} +pub(super) fn begin(app: &AppHandle, entry: &Operation) -> Reply<()> { + let state = app.state::(); + let _panels = state.native.0.lock().expect("native panels"); + check(app, entry)?; + entry.begin() +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Request { + binding_id: String, + action_id: Option, + selected_id: Option, + values: Option>, + operation_id: String, +} +fn parameters( + ui: &Value, + action: Option<&str>, + selected: Option<&str>, + values: Option>, +) -> Reply<(Value, Value)> { + let mut checked = ui.clone(); + let form = ui["kind"] == "form"; + if form { + let controls = checked["controls"] + .as_array_mut() + .ok_or_else(|| Error::new("plugin.function.invalidParams"))?; + let values = values + .as_ref() + .ok_or_else(|| Error::new("plugin.function.invalidParams"))?; + if values.len() != controls.len() { + return Err(Error::new("plugin.function.invalidParams")); + } + for control in controls { + control["value"] = values + .get(control["id"].as_str().unwrap_or_default()) + .cloned() + .ok_or_else(|| Error::new("plugin.function.invalidParams"))?; + } + } else if values.is_some() { + return Err(Error::new("plugin.function.invalidParams")); + } + if selected.is_some_and(|id| { + id.is_empty() + || id.len() > 128 + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + }) || (form && selected.is_some()) + { + return Err(Error::new("plugin.function.invalidParams")); + } + if let Some(action) = action { + let declaration = ui["actions"] + .as_array() + .into_iter() + .flatten() + .find(|item| item["id"] == action) + .ok_or_else(|| Error::new("plugin.function.unknownAction"))?; + if declaration["requiresSelection"] == true && selected.is_none() { + return Err(Error::new("plugin.function.invalidParams")); + } + } + let result = super::super::scan::core_execute( + "plugins.nativeUI.validateData", + json!({"ui":checked,"data":{"nodes":ui["nodes"]}}), + ) + .map_err(|_| Error::new("plugin.function.invalidParams"))?; + if result["status"] != "ok" { + return Err(Error::new("plugin.function.invalidParams")); + } + let mut params = Map::new(); + if action.is_some() { + params.insert("selectedID".into(), json!(selected)); + } + if form { + params.insert("values".into(), json!(values)); + } + Ok((checked, Value::Object(params))) +} + +#[tauri::command] +pub async fn declarative_plugins_native_function_prepare( + app: AppHandle, + window: WebviewWindow, + request: Request, +) -> Reply { + let owner = native_window_owner(&window.as_ref().window()); + let binding = app + .state::() + .native + .get(&request.binding_id, &owner)?; + check_binding(&app, &binding)?; + if binding + .busy + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(Error::new("plugin.function.busy")); + } + let permit = Permit { + binding: binding.clone(), + released: AtomicBool::new(false), + }; + let (ui, params) = parameters( + &binding.ui, + request.action_id.as_deref(), + request.selected_id.as_deref(), + request.values, + )?; + let call = Call { + binding: binding.clone(), + _permit: permit, + action: request.action_id.clone(), + ui, + params, + }; + let action = format!( + "__native__:{}:{}", + binding.id, + request.action_id.as_deref().unwrap_or("") + ); + prepare_owned( + app, + window, + binding.plugin_id.clone(), + action, + binding.workspace.clone(), + binding.setting_identity.clone(), + request.operation_id, + Some(call), + ) + .await +} + +#[tauri::command] +pub async fn declarative_plugins_native_function_close( + app: AppHandle, + window: WebviewWindow, + binding_id: String, +) -> Reply<()> { + let owner = native_window_owner(&window.as_ref().window()); + let state = app.state::(); + // A previously replaced/closed token is harmless, but never selects a new owner. + state + .native + .revoke(|binding| binding.id == binding_id && binding.owner.id == owner.id); + let prefix = format!("__native__:{binding_id}:"); + close_matching(&app, |context| { + context.window_incarnation == owner.id && context.action_id.starts_with(&prefix) + }) + .await + .map_err(|_| Error::new("plugin.function.cleanupFailed")) +} + +pub(super) fn result(entry: &Operation, value: Value) -> Reply { + let native = entry.native.lock().expect("native function call"); + let Some(call) = native.as_ref() else { + return Ok(value); + }; + validate_result(call, value) +} + +fn validate_result(call: &Call, value: Value) -> Reply { + if serde_json::to_vec(&value).map_or(true, |bytes| bytes.len() > 1024 * 1024) { + return Err(Error::new("plugin.function.resultTooLarge")); + } + if call.action.is_none() { + let checked = super::super::scan::core_execute( + "plugins.nativeUI.validateData", + json!({"ui":call.ui,"data":value}), + ) + .map_err(|_| Error::new("plugin.function.invalidResult"))?; + if checked["status"] != "ok" { + return Err(Error::new("plugin.function.invalidResult")); + } + } + Ok(value) +} + +pub(super) fn release(entry: &Operation) { + if let Some(call) = entry.native.lock().expect("native function call").as_ref() { + call._permit.release(); + } +} + +#[cfg(test)] +mod tests; diff --git a/windows/tauri/src-tauri/src/plugin_host/functions/native/tests.rs b/windows/tauri/src-tauri/src/plugin_host/functions/native/tests.rs new file mode 100644 index 0000000000..de6931a22b --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/functions/native/tests.rs @@ -0,0 +1,258 @@ +use super::super::super::build_registry::BuildPackageInput; +use super::*; + +fn fixtures() -> Vec { + [ + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/contracts/plugins/v1/fixtures/native-panel.plugin.json" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/contracts/plugins/v1/fixtures/native-form.plugin.json" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/contracts/plugins/v1/fixtures/native-controls.plugin.json" + )), + ] + .into_iter() + .map(|text| serde_json::from_str(text).unwrap()) + .collect() +} +fn binding(id: &str, owner: u64, registration: u64) -> Arc { + let manifest = fixtures().remove(0); + let tool = manifest["ui"]["toolWindows"][0]["id"] + .as_str() + .unwrap() + .to_owned(); + Arc::new(Binding { + id: id.into(), + registration, + window: "main".into(), + workspace: "/fixture/project".into(), + plugin_id: manifest["id"].as_str().unwrap().into(), + tool_window: tool.clone(), + ui: declaration(&manifest, &tool).unwrap(), + package_identity: "bytes".into(), + setting_identity: "settings".into(), + owner: Arc::new(WindowOwner { + id: owner, + alive: AtomicBool::new(true), + }), + context: BuildModelContext { + window_label: "main".into(), + workspace_root: "/fixture/project".into(), + generation: 1, + plugin_id: manifest["id"].as_str().unwrap().into(), + build_system_id: "sample".into(), + root: ".".into(), + package: BuildPackageInput { + plugin_id: manifest["id"].as_str().unwrap().into(), + version: "fixture".into(), + manifest, + content_identity: "bytes".into(), + }, + options: Default::default(), + model: Value::Null, + model_stale: false, + revision: 1, + }, + alive: AtomicBool::new(true), + busy: AtomicBool::new(false), + }) +} + +#[test] +fn three_real_native_fixtures_keep_declarations_and_current_form_values_in_core() { + for manifest in fixtures() { + let ui = declaration( + &manifest, + manifest["ui"]["toolWindows"][0]["id"].as_str().unwrap(), + ) + .unwrap(); + let values = (ui["kind"] == "form").then(|| { + ui["controls"] + .as_array() + .unwrap() + .iter() + .map(|control| { + ( + control["id"].as_str().unwrap().to_owned(), + control["value"].clone(), + ) + }) + .collect() + }); + let (_, params) = parameters(&ui, None, None, values.clone()).unwrap(); + if ui["kind"] == "form" { + assert_eq!(params, json!({"values":values})); + } else { + assert_eq!(params, json!({})); + } + for action in ui["actions"] + .as_array() + .unwrap() + .iter() + .filter(|action| action["kind"] == "invoke") + { + let id = action["id"].as_str().unwrap(); + let (selected, build) = selection(&ui, Some(id)).unwrap(); + assert_eq!(selected["function"], action["function"]); + assert_eq!(build, ui["source"]["buildSystemID"]); + let (_, params) = parameters( + &ui, + Some(id), + (ui["kind"] != "form").then_some("node"), + values.clone(), + ) + .unwrap(); + assert_eq!( + params["selectedID"], + if ui["kind"] == "form" { + Value::Null + } else { + json!("node") + } + ); + } + assert!(declaration(&manifest, "another.plugin.window").is_err()); + assert!(selection(&ui, Some("frontend.arbitrary.function")).is_err()); + } +} + +#[test] +fn full_form_validation_rejects_unknown_missing_wrong_type_and_uncommitted_illegal_values() { + let manifest = fixtures().remove(2); + let ui = manifest["ui"]["toolWindows"][0]["native"].clone(); + let values: Map = ui["controls"] + .as_array() + .unwrap() + .iter() + .map(|control| { + ( + control["id"].as_str().unwrap().into(), + control["value"].clone(), + ) + }) + .collect(); + for value in [ + json!(false), + json!("x".repeat(1025)), + json!({"program":"arbitrary"}), + ] { + let mut invalid = values.clone(); + invalid.insert("name".into(), value); + assert!(parameters(&ui, Some("changed"), None, Some(invalid)).is_err()); + } + let mut missing = values.clone(); + missing.remove("name"); + assert!(parameters(&ui, None, None, Some(missing)).is_err()); + let mut extra = values.clone(); + extra.insert("program".into(), json!("run")); + assert!(parameters(&ui, None, None, Some(extra)).is_err()); + assert!(parameters(&ui, None, Some("node"), Some(values)).is_err()); + assert!(serde_json::from_value::( + json!({"bindingId":"one","operationId":"operation","program":"arbitrary"}) + ) + .is_err()); +} + +#[test] +fn replacing_a_panel_and_late_close_preserve_the_new_handle_and_other_native_window() { + let panels = Panels::default(); + let old = binding("old", 1, 1); + let new = binding("new", 1, 2); + let other = binding("other", 2, 3); + panels.insert(old.clone(), |_| Ok(())).unwrap(); + panels.insert(other.clone(), |_| Ok(())).unwrap(); + assert!(panels + .insert(new.clone(), |_| Err(Error::new( + "plugin.function.staleContext" + ))) + .is_err()); + assert!( + panels.get("old", &old.owner).is_ok(), + "failed late admission preserves the prior owner" + ); + panels.insert(new.clone(), |_| Ok(())).unwrap(); + assert!(!old.alive.load(Ordering::Acquire)); + panels.revoke(|binding| binding.id == "old"); + assert!(panels.get("new", &new.owner).is_ok()); + assert!(panels.get("other", &new.owner).is_err()); + panels.revoke(|binding| binding.owner.id == 1); + assert!(panels.get("new", &new.owner).is_err()); + assert!(panels.get("other", &other.owner).is_ok()); +} + +#[test] +fn a_late_old_open_cannot_replace_the_newer_registered_panel() { + let panels = Panels::default(); + let old = binding("old", 1, 1); + let new = binding("new", 1, 2); + // The old request starts first, but its async catalog/model lookup + // completes only after the replacement is registered. + panels.insert(new.clone(), |_| Ok(())).unwrap(); + assert_eq!( + panels.insert(old, |_| Ok(())).unwrap_err().code, + "plugin.function.staleContext" + ); + panels.revoke(|binding| binding.id == "old"); + assert!(panels.get("new", &new.owner).is_ok()); + assert!(new.alive.load(Ordering::Acquire)); +} + +#[test] +fn an_old_released_permit_cannot_clear_a_later_approval_or_cleanup_slot() { + let binding = binding("one", 1, 1); + binding.busy.store(true, Ordering::Release); + let old = Permit { + binding: binding.clone(), + released: AtomicBool::new(false), + }; + old.release(); + assert!(!binding.busy.swap(true, Ordering::AcqRel)); + drop(old); + assert!(binding.busy.load(Ordering::Acquire)); +} + +#[test] +fn native_source_never_guesses_between_missing_multiple_or_foreign_instances() { + let instance = json!({"pluginID":"plugin","buildSystemID":"system","root":"nested"}); + assert!(unique_root(&[], "plugin", "system").is_err()); + assert!(unique_root(&[instance.clone(), instance.clone()], "plugin", "system").is_err()); + assert!(unique_root(&[instance.clone()], "foreign", "system").is_err()); + assert!(unique_root(&[instance.clone()], "plugin", "foreign").is_err()); + assert_eq!( + unique_root(&[instance], "plugin", "system").unwrap(), + "nested" + ); +} + +#[test] +fn source_results_have_the_shared_core_node_rules_and_one_mib_budget() { + let binding = binding("one", 1, 1); + let call = Call { + ui: binding.ui.clone(), + binding: binding.clone(), + _permit: Permit { + binding, + released: AtomicBool::new(false), + }, + action: None, + params: json!({}), + }; + assert!(validate_result(&call, json!({"nodes":[{"id":"a","label":"Valid"}]})).is_ok()); + assert!(validate_result( + &call, + json!({"nodes":[{"id":"a","label":"A"},{"id":"a","label":"Duplicate"}]}) + ) + .is_err()); + assert!(validate_result(&call, json!({"nodes":[],"program":"arbitrary"})).is_err()); + assert_eq!( + validate_result(&call, json!({"nodes":[],"large":"x".repeat(1024*1024)})) + .unwrap_err() + .code, + "plugin.function.resultTooLarge" + ); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/functions/runtime.rs b/windows/tauri/src-tauri/src/plugin_host/functions/runtime.rs new file mode 100644 index 0000000000..34bc93769e --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/functions/runtime.rs @@ -0,0 +1,532 @@ +//! Isolated action owner around Core's transport. Unknown destroy outcomes +//! retain their original join handle; retry never sends a duplicate destroy. +use super::{check, check_context, Error, ReceiptOwner, Reply, Review, WindowOwner}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::Duration; +use tauri::AppHandle; +use tokio::sync::Notify; + +pub(super) trait Core: Send + Sync { + fn execute(&self, command: &str, payload: Value) -> Reply; + fn start(&self, payload: Value, registered: &dyn Fn(&str)) -> Reply; +} +struct HostCore; +impl Core for HostCore { + fn execute(&self, command: &str, payload: Value) -> Reply { + let reply: Value = serde_json::from_str(&lithe_core::execute_json( + &json!({"command":command,"payload":payload}).to_string(), + )) + .map_err(|_| Error::new("plugin.function.invalidResult"))?; + if reply["ok"] == true { + Ok(reply["data"].clone()) + } else { + Err(Error::core(&reply["error"])) + } + } + fn start(&self, payload: Value, registered: &dyn Fn(&str)) -> Reply { + lithe_core::start_lsp_owned(payload, registered).map_err(|error| Error::core(&json!(error))) + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Phase { + Review, + Starting, + Running, + Succeeded, + Failed, + Cancelled, + CleanupFailed, +} +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Snapshot { + pub context: super::Context, + pub phase: Phase, + pub progress: Option, + pub result: Option, + pub error: Option, +} +pub(super) struct Status { + pub phase: Phase, + pub session: Option, + pub launch_finished: bool, + launch_panicked: bool, + pub destroyed: bool, + destroy_uncertain: bool, + pub progress: Option, + pub result: Option, + pub error: Option, +} +pub(super) struct Operation { + pub(super) native: Mutex>, + pub review: Review, + pub(super) owner: Arc, + pub(super) scope_revision: u64, + params: Mutex, + directory: PathBuf, + pub status: Mutex, + cancelled: AtomicBool, + retire_requested: AtomicBool, + registry: Mutex>, + changed: Notify, + cleanup_lock: tokio::sync::Mutex<()>, + destroy_job: tokio::sync::Mutex>>>, + pub(super) core: Arc, + lease: Mutex>, +} +struct CleanupRegistry { + operations: Weak>>>, + retired: Weak>>, +} +impl Operation { + pub fn new( + review: Review, + directory: PathBuf, + lease: super::super::lifecycle::NativeLease, + owner: Arc, + ) -> Self { + Self { + native: Mutex::new(None), + review, + owner, + scope_revision: 0, + params: Mutex::new(json!({})), + directory, + status: Mutex::new(Status { + phase: Phase::Review, + session: None, + launch_finished: true, + launch_panicked: false, + destroyed: false, + destroy_uncertain: false, + progress: None, + result: None, + error: None, + }), + cancelled: AtomicBool::new(false), + retire_requested: AtomicBool::new(false), + registry: Mutex::new(None), + changed: Notify::new(), + cleanup_lock: Default::default(), + destroy_job: Default::default(), + core: Arc::new(HostCore), + lease: Mutex::new(Some(lease)), + } + } + pub fn phase(&self) -> Phase { + self.status.lock().expect("function state").phase + } + pub fn cancelled(&self) -> bool { + self.cancelled.load(Ordering::Acquire) + } + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + self.changed.notify_waiters(); + } + pub fn clean(&self) -> bool { + let status = self.status.lock().expect("function state"); + status.launch_finished + && (status.destroyed || (status.session.is_none() && !status.launch_panicked)) + } + pub fn begin(&self) -> Reply<()> { + let mut status = self.status.lock().expect("function state"); + if self.cancelled() || status.phase != Phase::Review { + return Err(Error::new("plugin.function.staleContext")); + } + status.phase = Phase::Starting; + status.launch_finished = false; + Ok(()) + } + pub fn snapshot(&self) -> Snapshot { + let status = self.status.lock().expect("function state"); + Snapshot { + context: self.review.context.clone(), + phase: status.phase, + progress: status.progress.clone(), + result: status.result.clone(), + error: status.error.clone(), + } + } + pub fn set_params(&self, params: Value) { + *self.params.lock().expect("function parameters") = params; + } + fn params(&self) -> Value { + self.params.lock().expect("function parameters").clone() + } + pub(super) fn bind_registry( + &self, + operations: &Arc>>>, + retired: &Arc>>, + ) { + *self.registry.lock().expect("function cleanup registry") = Some(CleanupRegistry { + operations: Arc::downgrade(operations), + retired: Arc::downgrade(retired), + }); + } + pub(super) fn retire_clean(self: &Arc) { + if !self.retire_requested.load(Ordering::Acquire) || !self.clean() { + return; + } + let registry = self.registry.lock().expect("function cleanup registry"); + let Some((operations, retired)) = registry.as_ref().and_then(|registry| { + Some((registry.operations.upgrade()?, registry.retired.upgrade()?)) + }) else { + return; + }; + let mut operations = operations.lock().expect("function operations"); + let id = &self.review.context.operation_id; + if operations + .get(id) + .is_some_and(|current| Arc::ptr_eq(current, self)) + { + if !self.review.context.action_id.starts_with("__web__:") { + let mut retired = retired.lock().expect("retired function operations"); + if self.owner.alive.load(Ordering::Acquire) { + retired.insert( + id.clone(), + ReceiptOwner { + window: self.review.context.window.clone(), + incarnation: self.owner.id, + }, + ); + } + } + operations.remove(id); + super::native::release(self); + } + } + fn cleanup_after_completion(self: &Arc) { + if !self.cancelled() { + return; + } + // A destroyed window has no UI left to retry an expired cleanup. + // Launch and successful disposal completions each own one bounded + // follow-up; known failures still retain the lease for explicit retry. + let entry = self.clone(); + tauri::async_runtime::spawn(async move { + if cleanup_inner(&entry, Duration::from_secs(8)).await.is_ok() { + entry.retire_clean(); + entry.changed.notify_waiters(); + } + }); + } + fn complete_launch(self: &Arc, outcome: std::thread::Result>) { + let mut state = self.status.lock().expect("function state"); + state.launch_finished = true; + let result = outcome.unwrap_or_else(|_| { + // A panic before the owned-registration callback is not proof + // that Core never created a process. Keep the package lease. + state.launch_panicked = true; + Err(Error::new("plugin.function.workerFailed")) + }); + if let Err(error) = result { + state.error = Some(error); + } + drop(state); + self.changed.notify_waiters(); + self.cleanup_after_completion(); + } +} + +async fn call(entry: &Arc, command: &'static str, payload: Value) -> Reply { + let core = entry.core.clone(); + tokio::time::timeout( + Duration::from_secs(5), + tauri::async_runtime::spawn_blocking(move || core.execute(command, payload)), + ) + .await + .map_err(|_| Error::new("plugin.function.transportTimeout"))? + .map_err(|_| Error::new("plugin.function.workerFailed"))? +} + +async fn verify(app: &AppHandle, entry: &Arc) -> Reply<()> { + let app = app.clone(); + let entry = entry.clone(); + tokio::time::timeout( + Duration::from_secs(5), + tauri::async_runtime::spawn_blocking(move || check(&app, &entry)), + ) + .await + .map_err(|_| Error::new("plugin.function.timeout"))? + .map_err(|_| Error::new("plugin.function.workerFailed"))? +} +pub(super) async fn cleanup(entry: &Arc) -> Reply<()> { + entry.retire_requested.store(true, Ordering::Release); + cleanup_with_budget(entry, Duration::from_secs(8)).await +} +pub(super) async fn cleanup_with_budget(entry: &Arc, budget: Duration) -> Reply<()> { + entry.cancel(); + let outcome = cleanup_inner(entry, budget).await; + if let Err(error) = &outcome { + let mut state = entry.status.lock().expect("function state"); + state.phase = Phase::CleanupFailed; + state.error = Some(error.clone()); + } + outcome +} +async fn cleanup_inner(entry: &Arc, budget: Duration) -> Reply<()> { + let _serial = tokio::time::timeout(Duration::from_secs(12), entry.cleanup_lock.lock()) + .await + .map_err(|_| Error::new("plugin.function.cleanupFailed"))?; + let deadline = tokio::time::Instant::now() + budget; + loop { + let changed = entry.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + let (session, destroyed, finished, launch_panicked, destroy_uncertain) = { + let state = entry.status.lock().expect("function state"); + ( + state.session.clone(), + state.destroyed, + state.launch_finished, + state.launch_panicked, + state.destroy_uncertain, + ) + }; + if destroy_uncertain || (finished && session.is_none() && launch_panicked) { + return Err(Error::new("plugin.function.cleanupFailed")); + } + if let Some(session) = session.filter(|_| !destroyed) { + let mut job = entry.destroy_job.lock().await; + if job.is_none() { + let core = entry.core.clone(); + let operation = entry.review.context.operation_id.clone(); + let owner = entry.clone(); + *job = Some(tauri::async_runtime::spawn_blocking(move || { + let _ = core.execute( + "lsp.cancelOperation", + json!({"sessionId":session,"operationId":operation}), + ); + core.execute("lsp.stopServer", json!({"sessionId":session}))?; + let result = core.execute("lsp.destroyServer", json!({"sessionId":session})); + if result.is_ok() { + // Store success before the timed-out waiter can lose + // this non-idempotent Core session's completion proof. + owner.status.lock().expect("function state").destroyed = true; + owner.changed.notify_waiters(); + owner.cleanup_after_completion(); + } + result + })); + } + match tokio::time::timeout_at(deadline, job.as_mut().expect("owned destroy")).await { + Ok(result) => { + *job = None; + result + .map_err(|_| { + // A panicked destroy worker has an unknown outcome; + // replaying destroy could target an already removed ID. + entry + .status + .lock() + .expect("function state") + .destroy_uncertain = true; + Error::new("plugin.function.cleanupFailed") + })? + .map_err(|_| Error::new("plugin.function.cleanupFailed"))?; + entry.status.lock().expect("function state").destroyed = true; + entry.changed.notify_waiters(); + } + Err(_) => return Err(Error::new("plugin.function.cleanupFailed")), + } + continue; + } + if finished { + entry.lease.lock().expect("function lease").take(); + return Ok(()); + } + tokio::time::timeout_at(deadline, &mut changed) + .await + .map_err(|_| Error::new("plugin.function.cleanupFailed"))?; + } +} + +fn launch_payload(entry: &Operation) -> Reply { + let context = &entry.review.context; + let program = &entry.review.program; + let mut environment: std::collections::BTreeMap = std::env::vars().collect(); + super::super::launch::merge_environment(&mut environment, program.environment.clone()); + Ok( + json!({"providerId":format!("settingsAction:{}:{}",context.plugin_id,context.operation_id),"pluginId":context.plugin_id, + "executablePath":program.server_path,"arguments":program.server_args,"environment":environment, + "workingDirectory":context.workspace_path,"rootUri":url::Url::from_directory_path(&context.workspace_path).map_err(|_|Error::new("plugin.function.staleContext"))?.as_str(), + "initializationOptions":program.initialization_options,"workspaceConfiguration":program.workspace_configuration, + "hostServices":[],"initializeTimeoutMilliseconds":10000,"requestTimeoutMilliseconds":30000,"shutdownTimeoutMilliseconds":2000}), + ) +} + +pub(super) async fn run(app: AppHandle, entry: Arc) { + let worker = entry.clone(); + let worker_app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + check(&worker_app, &worker)?; + let plan = &worker.review.launch_plan; + let workspace = worker + .directory + .join(super::super::store::workspace_state_key( + &worker.review.context.workspace_path, + )); + super::super::launch::prepare_required_directories(plan, &worker.directory, &workspace) + .map_err(|_| Error::new("plugin.function.startFailed"))?; + std::fs::create_dir_all(&worker.directory) + .map_err(|_| Error::new("plugin.function.startFailed"))?; + super::super::launch::validate_resolved_launch_checked( + plan, + &worker.review.program, + &worker.directory, + || check_context(&worker_app, &worker).map_err(|error| error.code), + ) + .map_err(|_| Error::new("plugin.function.startFailed"))?; + check(&worker_app, &worker)?; + worker.core.start(launch_payload(&worker)?, &|id| { + worker.status.lock().expect("function state").session = Some(id.into()); + worker.changed.notify_waiters(); + }) + })); + worker.complete_launch(outcome); + }); + let result = tokio::time::timeout(Duration::from_secs(180), invoke(&app, &entry)) + .await + .unwrap_or_else(|_| Err(Error::new("plugin.function.timeout"))); + let was_cancelled = entry.cancelled(); + // Keep a completed action readable until its UI consumes the result. + if cleanup_with_budget(&entry, Duration::from_secs(8)) + .await + .is_ok() + { + let mut state = entry.status.lock().expect("function state"); + match result { + Ok(value) if !was_cancelled => { + state.result = Some(value); + state.phase = Phase::Succeeded; + state.error = None; + } + _ if was_cancelled => { + state.phase = Phase::Cancelled; + state.error = None; + } + Err(error) => { + state.phase = Phase::Failed; + state.error = Some(error); + } + _ => { + state.phase = Phase::Cancelled; + } + } + } +} + +async fn invoke(app: &AppHandle, entry: &Arc) -> Reply { + let mut ready = false; + let mut negotiated = false; + let mut functions = None; + let mut dispatched = false; + loop { + check_context(app, entry)?; + let (session, finished, error) = { + let state = entry.status.lock().expect("function state"); + ( + state.session.clone(), + state.launch_finished, + state.error.clone(), + ) + }; + if let Some(error) = error { + return Err(error); + } + if let Some(session) = session { + let events = call( + entry, + "lsp.waitEvents", + json!({"sessionId":session,"timeoutMilliseconds":250}), + ) + .await?; + for event in events["events"].as_array().into_iter().flatten() { + if event["sessionId"] != session { + continue; + } + match event["type"].as_str() { + Some("stateChanged") if event["state"] == "ready" => ready = true, + Some("stateChanged") + if matches!( + event["state"].as_str(), + Some("failed" | "stopping" | "stopped") + ) => + { + return Err(Error::new("plugin.function.sessionEnded")) + } + Some("functionsChanged") => { + functions = Some(event["result"]["functions"].clone()) + } + Some("featuresChanged") => { + negotiated = event["capabilities"].as_array().is_some_and(|features| { + features.iter().any(|feature| feature == "litheFunctions") + }); + if !negotiated { + return Err(Error::new("plugin.function.unavailable")); + } + } + Some("hostRequest") => { + call(entry,"lsp.hostResponse",json!({"sessionId":session,"hostRequestID":event["result"]["hostRequestID"],"error":{"message":"plugin.host.unavailable"}})).await?; + } + Some("requestProgress") + if dispatched + && event["operationId"] == entry.review.context.operation_id => + { + check_context(app, entry)?; + entry.status.lock().expect("function state").progress = + Some(event["result"].clone()); + } + Some("requestCompleted") + if dispatched + && event["operationId"] == entry.review.context.operation_id => + { + verify(app, entry).await?; + if !event["error"].is_null() { + return Err(Error::core(&event["error"])); + } + let value = event["result"] + .get("value") + .cloned() + .ok_or_else(|| Error::new("plugin.function.invalidResult"))?; + return super::native::result(entry, value); + } + _ => {} + } + } + if ready && finished && negotiated && functions.is_some() && !dispatched { + let functions = functions.as_ref().expect("function registry"); + if entry.review.context.function_id.is_empty() { + return Ok(functions.clone()); + } + if !functions.as_array().is_some_and(|items| { + items + .iter() + .any(|id| id == &entry.review.context.function_id) + }) { + return Err(Error::new("plugin.function.unknown")); + } + verify(app, entry).await?; + call(entry,"lsp.functionRequest",json!({"sessionId":session,"operationId":entry.review.context.operation_id, + "function":{"pluginID":entry.review.context.plugin_id,"functionID":entry.review.context.function_id},"params":entry.params(),"timeoutMilliseconds":120000})).await?; + dispatched = true; + entry.status.lock().expect("function state").phase = Phase::Running; + } + } else if finished { + return Err(Error::new("plugin.function.startFailed")); + } else { + tokio::select! { _=entry.changed.notified()=>{}, _=tokio::time::sleep(Duration::from_millis(50))=>{} } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/windows/tauri/src-tauri/src/plugin_host/functions/runtime/tests.rs b/windows/tauri/src-tauri/src/plugin_host/functions/runtime/tests.rs new file mode 100644 index 0000000000..4438002324 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/functions/runtime/tests.rs @@ -0,0 +1,468 @@ +use super::*; +use std::sync::atomic::AtomicUsize; +use tokio::sync::oneshot; + +#[derive(Default)] +struct ScriptedCore { + calls: Mutex>, + fail_destroy: AtomicBool, + panic_destroy: AtomicBool, + destroy_calls: AtomicUsize, + entered: Mutex>>, + release: Mutex>>, + start_entered: Mutex>>, + start_release: Mutex>>, +} +impl Core for ScriptedCore { + fn execute(&self, command: &str, payload: Value) -> Reply { + self.calls.lock().unwrap().push((command.into(), payload)); + if command == "lsp.destroyServer" { + self.destroy_calls.fetch_add(1, Ordering::SeqCst); + if self.panic_destroy.load(Ordering::SeqCst) { + panic!("fixture destroy outcome unknown"); + } + if let Some(entered) = self.entered.lock().unwrap().take() { + let _ = entered.send(()); + } + if let Some(release) = self.release.lock().unwrap().take() { + release + .recv_timeout(Duration::from_secs(2)) + .expect("test releases its bounded destroy gate"); + } + if self.fail_destroy.load(Ordering::SeqCst) { + return Err(Error::new("fixture.cleanupFailed")); + } + } + Ok(json!({})) + } + fn start(&self, _payload: Value, registered: &dyn Fn(&str)) -> Reply { + if let Some(entered) = self.start_entered.lock().unwrap().take() { + let _ = entered.send(()); + } + if let Some(release) = self.start_release.lock().unwrap().take() { + release + .recv_timeout(Duration::from_secs(2)) + .expect("test releases its bounded start gate"); + } + registered("owned-session"); + Ok(json!({"sessionId":"owned-session"})) + } +} +fn fixture(core: Arc) -> Arc { + let review = Review { + context: super::super::Context { + window: "workspace-one".into(), + window_incarnation: 7, + workspace_path: if cfg!(windows) { + "C:/fixture/project" + } else { + "/fixture/project" + } + .into(), + generation: 3, + plugin_id: "example.plugin".into(), + package_identity: "all-package-bytes".into(), + setting_identity: "global-project-bytes".into(), + action_id: "refresh".into(), + function_id: "snapshot.refresh".into(), + operation_id: "operation-one".into(), + }, + title: "Refresh".into(), + confirm: true, + executes_project_code: true, + may_use_network: false, + launch_plan: json!({}), + program: super::super::super::launch::ResolvedLaunch { + plugin_id: "example.plugin".into(), + language_id: "fixture".into(), + server_path: "/fixture/backend".into(), + server_args: vec![], + initialization_options: None, + workspace_configuration: None, + environment: Default::default(), + }, + }; + let lease = super::super::super::lifecycle::LifecycleState::default() + .acquire("example.plugin") + .unwrap(); + let owner = Arc::new(WindowOwner { + id: 7, + alive: AtomicBool::new(true), + }); + let mut entry = Operation::new(review, PathBuf::from("/fixture/state"), lease, owner); + entry.core = core; + Arc::new(entry) +} +struct ReleaseOnDrop(Option>); +impl Drop for ReleaseOnDrop { + fn drop(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(()); + } + } +} + +fn publish(entry: &Arc) -> super::super::FunctionsState { + let registry = super::super::FunctionsState::default(); + entry.bind_registry(®istry.operations, ®istry.retired); + registry + .operations + .lock() + .unwrap() + .insert(entry.review.context.operation_id.clone(), entry.clone()); + registry +} + +async fn await_retired(entry: &Arc, registry: &super::super::FunctionsState) { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let changed = entry.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + let no_lease = entry.lease.lock().unwrap().is_none(); + let no_owner = !registry + .operations + .lock() + .unwrap() + .contains_key(&entry.review.context.operation_id); + if no_lease && no_owner { + break; + } + changed.await; + } + }) + .await + .expect("the completed original owner retires without a UI retry"); +} + +#[tokio::test] +async fn a_start_finishing_after_the_last_window_cleanup_is_reaped_automatically() { + let core = Arc::new(ScriptedCore::default()); + let (entered_tx, entered_rx) = oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + *core.start_entered.lock().unwrap() = Some(entered_tx); + *core.start_release.lock().unwrap() = Some(release_rx); + let mut release = ReleaseOnDrop(Some(release_tx)); + let entry = fixture(core.clone()); + let registry = publish(&entry); + entry.begin().unwrap(); + let worker = entry.clone(); + let (done_tx, done_rx) = oneshot::channel(); + tauri::async_runtime::spawn_blocking(move || { + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + worker.core.start(json!({}), &|id| { + worker.status.lock().unwrap().session = Some(id.into()); + }) + })); + worker.complete_launch(outcome); + let _ = done_tx.send(()); + }); + tokio::time::timeout(Duration::from_secs(1), entered_rx) + .await + .unwrap() + .unwrap(); + // The final window cleanup is already gone before native registration. + entry.retire_requested.store(true, Ordering::Release); + assert!(cleanup_with_budget(&entry, Duration::ZERO).await.is_err()); + assert!(entry.lease.lock().unwrap().is_some()); + release.0.take().unwrap().send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(1), done_rx) + .await + .unwrap() + .unwrap(); + await_retired(&entry, ®istry).await; + assert_eq!(core.destroy_calls.load(Ordering::SeqCst), 1); + assert_eq!( + registry + .retired + .lock() + .unwrap() + .get("operation-one") + .map(|owner| owner.window.as_str()), + Some("workspace-one") + ); +} + +#[tokio::test] +async fn a_destroy_finishing_after_the_last_window_cleanup_keeps_its_success_proof() { + let core = Arc::new(ScriptedCore::default()); + let (entered_tx, entered_rx) = oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + *core.entered.lock().unwrap() = Some(entered_tx); + *core.release.lock().unwrap() = Some(release_rx); + let mut release = ReleaseOnDrop(Some(release_tx)); + let entry = fixture(core.clone()); + let registry = publish(&entry); + entry.status.lock().unwrap().session = Some("owned-session".into()); + entry.retire_requested.store(true, Ordering::Release); + assert!(cleanup_with_budget(&entry, Duration::ZERO).await.is_err()); + tokio::time::timeout(Duration::from_secs(1), entered_rx) + .await + .unwrap() + .unwrap(); + assert!(entry.lease.lock().unwrap().is_some()); + release.0.take().unwrap().send(()).unwrap(); + await_retired(&entry, ®istry).await; + assert_eq!(core.destroy_calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn web_destroy_completion_retires_the_original_owner_without_a_tombstone() { + let core = Arc::new(ScriptedCore::default()); + let (entered_tx, entered_rx) = oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + *core.entered.lock().unwrap() = Some(entered_tx); + *core.release.lock().unwrap() = Some(release_rx); + let mut release = ReleaseOnDrop(Some(release_tx)); + let mut entry = fixture(core.clone()); + Arc::get_mut(&mut entry).unwrap().review.context.action_id = + "__web__:fixture:snapshot.refresh".into(); + let registry = publish(&entry); + entry.status.lock().unwrap().session = Some("owned-session".into()); + entry.retire_requested.store(true, Ordering::Release); + assert!(cleanup_with_budget(&entry, Duration::ZERO).await.is_err()); + tokio::time::timeout(Duration::from_secs(1), entered_rx) + .await + .unwrap() + .unwrap(); + release.0.take().unwrap().send(()).unwrap(); + await_retired(&entry, ®istry).await; + assert!(registry.retired.lock().unwrap().is_empty()); + assert_eq!(core.destroy_calls.load(Ordering::SeqCst), 1); +} + +#[test] +fn late_old_window_cleanup_cannot_remove_or_cancel_a_new_owner_receipt() { + let old = fixture(Arc::new(ScriptedCore::default())); + let registry = publish(&old); + registry.retire_window(&old.owner); + let mut new = fixture(Arc::new(ScriptedCore::default())); + let new_mut = Arc::get_mut(&mut new).unwrap(); + new_mut.owner = Arc::new(WindowOwner { + id: 8, + alive: AtomicBool::new(true), + }); + new_mut.review.context.window_incarnation = 8; + new.bind_registry(®istry.operations, ®istry.retired); + registry + .operations + .lock() + .unwrap() + .insert("operation-one".into(), new.clone()); + assert!(!super::super::owns_operation(&old.owner, &new)); + assert!(super::super::owns_operation(&new.owner, &new)); + old.retire_requested.store(true, Ordering::Release); + old.retire_clean(); + assert!(Arc::ptr_eq( + ®istry.operations.lock().unwrap()["operation-one"], + &new + )); + assert!(!new.cancelled()); + assert!(registry.retired.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn completed_result_receipts_stay_readable_until_the_product_consumes_them() { + let core = Arc::new(ScriptedCore::default()); + let entry = fixture(core.clone()); + let registry = publish(&entry); + entry.status.lock().unwrap().session = Some("owned-session".into()); + cleanup_with_budget(&entry, Duration::from_secs(1)) + .await + .unwrap(); + assert!(entry.lease.lock().unwrap().is_none()); + assert!(registry + .operations + .lock() + .unwrap() + .contains_key("operation-one")); + assert!(registry.retired.lock().unwrap().is_empty()); + assert_eq!(core.destroy_calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn unknown_destroy_outcome_is_joined_and_never_dispatched_twice() { + let core = Arc::new(ScriptedCore::default()); + let (entered_tx, entered_rx) = oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + *core.entered.lock().unwrap() = Some(entered_tx); + *core.release.lock().unwrap() = Some(release_rx); + let mut release = ReleaseOnDrop(Some(release_tx)); + let entry = fixture(core.clone()); + entry.status.lock().unwrap().session = Some("owned-session".into()); + assert!(cleanup_with_budget(&entry, Duration::ZERO).await.is_err()); + tokio::time::timeout(Duration::from_secs(1), entered_rx) + .await + .unwrap() + .unwrap(); + assert!(entry.lease.lock().unwrap().is_some()); + release.0.take().unwrap().send(()).unwrap(); + cleanup_with_budget(&entry, Duration::from_secs(1)) + .await + .unwrap(); + assert_eq!(core.destroy_calls.load(Ordering::SeqCst), 1); + assert!(entry.lease.lock().unwrap().is_none()); +} + +#[tokio::test] +async fn panicked_destroy_keeps_its_unknown_owner_without_replaying_destroy() { + let core = Arc::new(ScriptedCore::default()); + core.panic_destroy.store(true, Ordering::SeqCst); + let entry = fixture(core.clone()); + entry.status.lock().unwrap().session = Some("owned-session".into()); + assert!(cleanup(&entry).await.is_err()); + assert!(cleanup(&entry).await.is_err()); + assert_eq!(core.destroy_calls.load(Ordering::SeqCst), 1); + assert!(!entry.clean()); + assert!(entry.lease.lock().unwrap().is_some()); +} + +#[tokio::test] +async fn panicked_start_without_a_registered_session_does_not_claim_cleanup() { + let entry = fixture(Arc::new(ScriptedCore::default())); + entry.begin().unwrap(); + entry.complete_launch(Err(Box::new("fixture start outcome unknown"))); + assert!(cleanup(&entry).await.is_err()); + assert!(!entry.clean()); + assert!(entry.lease.lock().unwrap().is_some()); +} + +#[tokio::test] +async fn late_start_completion_retains_package_lease_after_successful_destroy() { + let core = Arc::new(ScriptedCore::default()); + let entry = fixture(core.clone()); + { + let mut state = entry.status.lock().unwrap(); + state.session = Some("owned-session".into()); + state.launch_finished = false; + } + let cleaning = cleanup(&entry); + tokio::pin!(cleaning); + let destroyed = async { + loop { + let changed = entry.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + if entry.status.lock().unwrap().destroyed { + break; + } + changed.await; + } + }; + tokio::select! { biased; + result = &mut cleaning => panic!("lease released before the start worker exited: {}", result.is_ok()), + result = tokio::time::timeout(Duration::from_secs(2), destroyed) => result.unwrap(), + } + assert!(entry.status.lock().unwrap().destroyed); + assert!(entry.lease.lock().unwrap().is_some()); + entry.status.lock().unwrap().launch_finished = true; + entry.changed.notify_waiters(); + cleaning.await.unwrap(); + assert_eq!(core.destroy_calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn known_cleanup_failure_remains_owned_and_is_retryable() { + let core = Arc::new(ScriptedCore::default()); + core.fail_destroy.store(true, Ordering::SeqCst); + let entry = fixture(core.clone()); + entry.status.lock().unwrap().session = Some("owned-session".into()); + assert!(cleanup(&entry).await.is_err()); + assert!(matches!(entry.phase(), Phase::CleanupFailed)); + assert!(!entry.clean()); + assert!(entry.lease.lock().unwrap().is_some()); + core.fail_destroy.store(false, Ordering::SeqCst); + cleanup(&entry).await.unwrap(); + assert!(entry.clean()); + assert_eq!(core.destroy_calls.load(Ordering::SeqCst), 2); + for (_, payload) in core.calls.lock().unwrap().iter() { + assert_eq!(payload["sessionId"], "owned-session"); + } +} + +#[tokio::test] +async fn scoped_reload_drain_retains_failed_owner_and_never_cancels_other_scopes() { + let core = Arc::new(ScriptedCore::default()); + core.fail_destroy.store(true, Ordering::SeqCst); + let target = fixture(core.clone()); + target.status.lock().unwrap().session = Some("owned-session".into()); + let registry = publish(&target); + let mut others = Vec::new(); + for scope in ["plugin", "window", "workspace"] { + let other_core = Arc::new(ScriptedCore::default()); + let mut other = fixture(other_core.clone()); + let context = &mut Arc::get_mut(&mut other).unwrap().review.context; + context.operation_id = format!("other-{scope}"); + match scope { + "plugin" => context.plugin_id = "other.plugin".into(), + "window" => context.window = "workspace-other".into(), + _ => context.workspace_path = "C:/fixture/other".into(), + } + other.bind_registry(®istry.operations, ®istry.retired); + registry + .operations + .lock() + .unwrap() + .insert(other.review.context.operation_id.clone(), other.clone()); + others.push((other, other_core)); + } + let selected = |context: &super::super::Context| { + context.window == target.review.context.window + && context.workspace_path == target.review.context.workspace_path + && context.plugin_id == target.review.context.plugin_id + }; + assert!(tokio::time::timeout( + Duration::from_secs(2), + super::super::drain_matching(®istry, selected), + ) + .await + .unwrap() + .is_err()); + assert!(registry + .operations + .lock() + .unwrap() + .contains_key("operation-one")); + assert!(target.lease.lock().unwrap().is_some()); + assert!(target.cancelled()); + core.fail_destroy.store(false, Ordering::SeqCst); + tokio::time::timeout( + Duration::from_secs(2), + super::super::drain_matching(®istry, selected), + ) + .await + .unwrap() + .unwrap(); + assert!(!registry + .operations + .lock() + .unwrap() + .contains_key("operation-one")); + assert!(target.lease.lock().unwrap().is_none()); + assert_eq!(core.destroy_calls.load(Ordering::SeqCst), 2); + for (other, other_core) in others { + assert!(!other.cancelled()); + assert!(registry + .operations + .lock() + .unwrap() + .contains_key(&other.review.context.operation_id)); + assert_eq!(other_core.destroy_calls.load(Ordering::SeqCst), 0); + } +} + +#[test] +fn isolated_launch_offers_no_host_services_and_keeps_its_plugin_identity() { + let entry = fixture(Arc::new(ScriptedCore::default())); + let payload = launch_payload(&entry).unwrap(); + assert_eq!(payload["pluginId"], "example.plugin"); + assert_eq!(payload["hostServices"], json!([])); + assert_eq!( + payload["providerId"], + "settingsAction:example.plugin:operation-one" + ); + assert_eq!( + payload["workingDirectory"], + entry.review.context.workspace_path + ); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/functions/tests.rs b/windows/tauri/src-tauri/src/plugin_host/functions/tests.rs new file mode 100644 index 0000000000..92febef286 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/functions/tests.rs @@ -0,0 +1,155 @@ +use super::*; + +fn manifest() -> Value { + json!({"settingActions":[{"id":"refresh","title":"Refresh","function":"snapshot.refresh","confirm":true,"page":{"buildSystemID":"fixture"}}], + "buildSystems":[{"id":"fixture","provider":{"launch":{"kind":"packaged","path":"bin/provider"}}}, + {"id":"another","provider":{"launch":{"kind":"packaged","path":"bin/another"}}}]}) +} + +#[test] +fn action_and_provider_are_selected_only_from_the_same_manifest() { + let (action, system) = selection(&manifest(), "refresh").unwrap(); + assert_eq!(action["function"], "snapshot.refresh"); + assert_eq!(system, "fixture"); + assert!(action["confirm"].as_bool().unwrap()); + assert_eq!( + selection(&manifest(), "another-plugin-action") + .unwrap_err() + .code, + "plugin.function.unknownAction" + ); +} + +#[test] +fn missing_ambiguous_and_editor_shared_sources_are_explicitly_unavailable() { + let mut value = manifest(); + value["settingActions"][0]["page"] = json!({"buildSystemID":"missing"}); + assert_eq!( + selection(&value, "refresh").unwrap_err().code, + "plugin.function.providerUnavailable" + ); + value["settingActions"][0] + .as_object_mut() + .unwrap() + .remove("page"); + assert!( + selection(&value, "refresh").is_err(), + "cannot guess among two providers" + ); + value["settingActions"][0]["page"] = json!({"languageID":"fixture-language"}); + value["buildSystems"][0]["provider"] = json!({"languageServer":"fixture-language"}); + assert!( + selection(&value, "refresh").is_err(), + "must not adopt or stop an editor's shared session" + ); +} + +#[test] +fn shared_contract_scope_value_and_action_placement_stay_authoritative() { + let value: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/contracts/plugins/v1/fixtures/settings-v2.plugin.json" + ))) + .unwrap(); + let normalized = super::super::scan::validate_manifest_with(&value, None).unwrap(); + let setting = normalized["settings"] + .as_array() + .unwrap() + .iter() + .find(|setting| setting["id"] == "dialyzer") + .unwrap(); + assert_eq!(setting["scope"], "project"); + assert_eq!( + normalized["settingActions"][0]["function"], + "dialyzer.rebuild" + ); + assert!( + selection(&normalized, "rebuildPlt").is_err(), + "shared language provider remains unavailable for isolated actions" + ); +} + +#[test] +fn core_error_codes_and_plugin_error_codes_are_preserved() { + let error = Error::core( + &json!({"code":"permission_denied","details":"plugin.function.wrongPlugin","message":"wrong plugin"}), + ); + assert_eq!(error.code, "plugin.function.wrongPlugin"); + let error = Error::core( + &json!({"code":"plugin.function.failed","message":"backend rejected","pluginErrorCode":"fixture.failed"}), + ); + assert_eq!(error.plugin_error_code.as_deref(), Some("fixture.failed")); + let error = Error::core( + &json!({"code":"processFailed", "details":"private OS diagnostics", "message":"transport failed"}), + ); + assert_eq!(error.code, "processFailed"); +} + +#[test] +fn retired_receipts_are_reclaimed_only_for_the_destroyed_native_window() { + let state = FunctionsState::default(); + let mut first_window = tauri::ResourceTable::default(); + let first = table_window_owner(&mut first_window); + assert_eq!(table_window_owner(&mut first_window).id, first.id); + for index in 0..4096 { + state.retired.lock().unwrap().insert( + format!("operation-{index}"), + ReceiptOwner { + window: "main".into(), + incarnation: first.id, + }, + ); + } + let mut replacement_window = tauri::ResourceTable::default(); + let replacement = table_window_owner(&mut replacement_window); + assert_ne!( + first.id, replacement.id, + "reusing a label does not reuse native resources" + ); + state.retired.lock().unwrap().insert( + "new-owner-receipt".into(), + ReceiptOwner { + window: "main".into(), + incarnation: replacement.id, + }, + ); + state.retire_window(&first); + assert!(!first.alive.load(Ordering::Acquire)); + assert!(replacement.alive.load(Ordering::Acquire)); + assert_eq!(state.retired.lock().unwrap().len(), 1); + state.retire_window(&first); + assert_eq!( + state.retired.lock().unwrap()["new-owner-receipt"].incarnation, + replacement.id + ); +} + +#[test] +fn cleanup_retry_checks_the_real_native_incarnation_before_mutating_any_owner() { + let mut original_window = tauri::ResourceTable::default(); + let original = table_window_owner(&mut original_window); + let mut replacement_window = tauri::ResourceTable::default(); + let replacement = table_window_owner(&mut replacement_window); + assert!(check_expected_owner(&original, Some(original.id)).is_ok()); + assert!(check_expected_owner(&replacement, Some(original.id)).is_err()); + assert!(check_expected_owner(&replacement, Some(replacement.id)).is_ok()); + original.alive.store(false, Ordering::Release); + assert!(check_expected_owner(&original, Some(original.id)).is_err()); + assert!(replacement.alive.load(Ordering::Acquire)); +} + +#[test] +fn web_selection_uses_only_the_requested_dedicated_provider_and_allows_registry_discovery() { + let (action, provider) = selection(&manifest(), "__web__:fixture:").unwrap(); + assert_eq!(provider, "fixture"); + assert_eq!(action["function"], ""); + let (action, provider) = selection(&manifest(), "__web__:fixture:snapshot.refresh").unwrap(); + assert_eq!(provider, "fixture"); + assert_eq!(action["function"], "snapshot.refresh"); + assert_eq!( + selection(&manifest(), "__web__:missing:snapshot.refresh") + .unwrap_err() + .code, + "plugin.function.providerUnavailable" + ); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/functions/web.rs b/windows/tauri/src-tauri/src/plugin_host/functions/web.rs new file mode 100644 index 0000000000..c60e9c18d8 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/functions/web.rs @@ -0,0 +1,683 @@ +//! Page-owned functions bridge. Page call IDs are only local correlation keys; +//! every Core operation gets a fresh native ID and the R10 owner does all +//! process, negotiation, result and cleanup work. +use super::*; +use crate::plugin_host::web::{bridge_error, with_page_owner, BridgeIdentity, WebPageHostState}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter}; +use tokio::sync::Notify; + +const MAX_PAGE_CALLS: usize = 16; +const MAX_TOTAL_CALLS: usize = 128; +const MAX_CALL_IDS: usize = 4096; +const CALL_TIMEOUT: Duration = Duration::from_secs(180); +const MAX_RESULT_BYTES: usize = 1024 * 1024; +static NEXT_OPERATION: AtomicU64 = AtomicU64::new(1); + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Parameters { + #[serde(default)] + instance: Option, + #[serde(default)] + function: Option, + #[serde(default)] + params: Option, + #[serde(default)] + call: Option, +} + +struct Pending { + operation_id: String, + call: Option, + page_label: String, + identity: BridgeIdentity, + package_identity: String, + scope_revision: u64, + instance: String, + function: Option, + params: Value, + list: bool, + cancelled: AtomicBool, + failure: Mutex>, + deadline: Instant, + changed: Notify, +} +impl Pending { + fn cancel(&self) { + self.finish(None); + } + fn finish(&self, failure: Option) { + // Cancellation and refusal are competing terminal events. Keep the + // first event under one lock, including its optional backend code. + let mut outcome = self.failure.lock().expect("web function failure"); + if !self.cancelled.load(Ordering::Acquire) { + *outcome = failure; + self.cancelled.store(true, Ordering::Release); + } + drop(outcome); + self.changed.notify_waiters(); + } + fn check(&self, app: &AppHandle) -> Result<(), String> { + self.check_outcome()?; + self.check_scope(app)?; + if app + .state::() + .blocked(&self.identity.plugin_id) + { + return Err(bridge_error("plugin.web.forbidden", "plugin was revoked")); + } + with_page_owner( + &app.state::(), + &self.page_label, + &self.identity, + || Ok(()), + ) + } + fn check_scope(&self, app: &AppHandle) -> Result<(), String> { + if app + .state::() + .function_scope_revision( + &self.identity.host_label, + &self.identity.workspace_path, + &self.identity.plugin_id, + ) + .map_or(true, |revision| revision != self.scope_revision) + { + return Err(bridge_error( + "plugin.function.staleContext", + "function scope was reloaded", + )); + } + Ok(()) + } + fn verify_package(&self, app: &AppHandle) -> Result<(), String> { + self.check(app)?; + let current_package = super::super::settings::read_snapshot( + app, + &self.identity.plugin_id, + &self.identity.workspace_path, + ) + .map_err(|_| bridge_error("plugin.web.forbidden", "package identity is unavailable"))? + .package_identity; + if current_package != self.package_identity { + return Err(bridge_error("plugin.web.forbidden", "package was replaced")); + } + Ok(()) + } + fn check_outcome(&self) -> Result<(), String> { + let outcome = self.failure.lock().expect("web function failure"); + if let Some(error) = outcome.as_ref() { + return Err(serde_json::to_string(error).expect("function error")); + } + if self.cancelled.load(Ordering::Acquire) { + return Err(bridge_error("requestCancelled", "requestCancelled")); + } + if Instant::now() >= self.deadline { + return Err(bridge_error("requestTimeout", "requestTimeout")); + } + Ok(()) + } +} + +#[derive(Default)] +pub(crate) struct Calls(Mutex); +#[derive(Default)] +struct Ledger { + pending: HashMap>, + used: HashMap<(String, u64), HashSet>, +} +impl Calls { + fn for_host(&self, operation_id: &str, host: &str) -> Option> { + self.0 + .lock() + .expect("web function ledger") + .pending + .get(operation_id) + .filter(|pending| pending.identity.host_label == host) + .cloned() + } + pub(super) fn cancel_for_host( + &self, + operation_id: &str, + host: &str, + failure: Option, + ) -> bool { + let Some(pending) = self.for_host(operation_id, host) else { + return false; + }; + let failure = failure.map(|mut error| { + error.code = error.code.chars().take(128).collect(); + error.message = error.message.chars().take(1024).collect(); + error.plugin_error_code = error + .plugin_error_code + .map(|code| code.chars().take(128).collect()); + error + }); + pending.finish(failure); + true + } + pub(crate) fn active(&self, operation_id: &str, page_label: &str) -> bool { + self.0 + .lock() + .expect("web function ledger") + .pending + .get(operation_id) + .is_some_and(|pending| { + pending.page_label == page_label && !pending.cancelled.load(Ordering::Acquire) + }) + } + fn admit(&self, pending: Arc) -> Result<(), String> { + let mut ledger = self.0.lock().expect("web function ledger"); + ledger + .pending + .retain(|_, pending| !pending.cancelled.load(Ordering::Acquire)); + let page_count = ledger + .pending + .values() + .filter(|entry| { + entry.page_label == pending.page_label + && entry.identity.generation == pending.identity.generation + }) + .count(); + if ledger.pending.len() >= MAX_TOTAL_CALLS || page_count >= MAX_PAGE_CALLS { + return Err(bridge_error("plugin.function.busy", "plugin.function.busy")); + } + if let Some(call) = &pending.call { + let used = ledger + .used + .entry((pending.page_label.clone(), pending.identity.generation)) + .or_default(); + if used.len() >= MAX_CALL_IDS || !used.insert(call.clone()) { + return Err(bridge_error( + "plugin.web.invalidRequest", + "call ID was already used", + )); + } + } + ledger.pending.insert(pending.operation_id.clone(), pending); + Ok(()) + } + fn remove(&self, operation_id: &str) { + self.0 + .lock() + .expect("web function ledger") + .pending + .remove(operation_id); + } + fn cancel_call(&self, page: &str, generation: u64, call: &str) -> Option> { + let ledger = self.0.lock().expect("web function ledger"); + let pending = ledger + .pending + .values() + .find(|pending| { + pending.page_label == page + && pending.identity.generation == generation + && pending.call.as_deref() == Some(call) + })? + .clone(); + pending.cancel(); + Some(pending) + } + pub(crate) fn revoke_pages(&self, labels: &[String]) { + let mut ledger = self.0.lock().expect("web function ledger"); + for pending in ledger + .pending + .values() + .filter(|pending| labels.contains(&pending.page_label)) + { + pending.cancel(); + } + ledger.used.retain(|(page, _), _| !labels.contains(page)); + } + pub(super) fn revoke_scope(&self, window: &str, root: &str, plugin: &str) { + let ledger = self.0.lock().expect("web function ledger"); + for pending in ledger.pending.values().filter(|pending| { + pending.identity.host_label == window + && pending.identity.workspace_path == root + && pending.identity.plugin_id == plugin + }) { + pending.cancel(); + } + } +} + +pub(super) fn confirm(app: &AppHandle, entry: &Arc, host: &str) -> Reply<()> { + let state = app.state::(); + let id = &entry.review.context.operation_id; + let pending = state + .functions + .for_host(id, host) + .ok_or_else(|| Error::new("requestCancelled"))?; + pending + .verify_package(app) + .map_err(|error| Error::core(&serde_json::from_str(&error).unwrap_or(Value::Null)))?; + // Page revocation holds owners before the call ledger. Keep that lock order + // and make the final live-receipt check atomic with the operation's start. + with_page_owner(&state, &pending.page_label, &pending.identity, || { + let ledger = state.functions.0.lock().expect("web function ledger"); + if !ledger + .pending + .get(id) + .is_some_and(|current| Arc::ptr_eq(current, &pending)) + { + return Err(bridge_error("requestCancelled", "requestCancelled")); + } + pending.check_outcome()?; + entry + .begin() + .map_err(|error| serde_json::to_string(&error).expect("function error")) + }) + .map_err(|error| Error::core(&serde_json::from_str(&error).unwrap_or(Value::Null))) +} + +fn page_result(list: bool, result: Value) -> Value { + if list { + json!({"functions": result}) + } else { + json!({"value": result}) + } +} + +pub(crate) async fn request( + app: &AppHandle, + page_label: &str, + identity: BridgeIdentity, + method: &str, + value: Value, +) -> Result { + let parameters: Parameters = serde_json::from_value(value) + .map_err(|_| bridge_error("plugin.web.invalidRequest", "invalid function parameters"))?; + if parameters + .call + .as_ref() + .is_some_and(|call| call.is_empty() || call.len() > 128 || call.contains('\0')) + || parameters.instance.as_ref().is_some_and(|instance| { + instance.is_empty() || instance.len() > 1024 || instance.contains('\0') + }) + || parameters.function.as_ref().is_some_and(|function| { + function.is_empty() || function.len() > 128 || function.contains('\0') + }) + || serde_json::to_vec(¶meters.params) + .map(|bytes| bytes.len() > 64 * 1024) + .unwrap_or(true) + { + return Err(bridge_error( + "plugin.web.invalidRequest", + "invalid function parameters", + )); + } + if method == "functions.cancel" { + let call = parameters.call.as_deref().ok_or_else(|| { + bridge_error( + "plugin.web.invalidRequest", + "functions.cancel requires call", + ) + })?; + let pending = app.state::().functions.cancel_call( + page_label, + identity.generation, + call, + ); + if let Some(pending) = pending { + let _ = app.emit_to( + &pending.identity.host_label, + "plugin-web-function-cancelled", + json!({ "operationId": pending.operation_id }), + ); + } + return Ok(json!({})); + } + let scope_revision = app + .state::() + .function_scope_revision( + &identity.host_label, + &identity.workspace_path, + &identity.plugin_id, + ) + .map_err(|_| { + bridge_error( + "plugin.function.staleContext", + "function scope was reloaded", + ) + })?; + let instance = parameters.instance.clone().ok_or_else(|| { + bridge_error( + "plugin.function.unavailable", + "plugin backend functions are unavailable", + ) + })?; + let list = method == "functions.list"; + if (method != "functions.list" && method != "functions.invoke") + || (list && (parameters.function.is_some() || parameters.params.is_some())) + || (!list + && parameters + .function + .as_ref() + .is_none_or(|function| function.is_empty())) + || parameters + .params + .as_ref() + .is_some_and(|params| !params.is_object()) + { + return Err(bridge_error( + "plugin.web.invalidRequest", + "invalid function parameters", + )); + } + let operation_id = format!( + "web-function-{}", + NEXT_OPERATION.fetch_add(1, Ordering::Relaxed) + ); + let package_identity = + super::super::settings::read_snapshot(app, &identity.plugin_id, &identity.workspace_path) + .map_err(|_| bridge_error("plugin.web.forbidden", "package identity is unavailable"))? + .package_identity; + let pending = Arc::new(Pending { + operation_id: operation_id.clone(), + call: parameters.call.clone(), + page_label: page_label.into(), + identity, + package_identity, + scope_revision, + instance, + function: parameters.function, + params: parameters.params.unwrap_or_else(|| json!({})), + list, + cancelled: AtomicBool::new(false), + failure: Mutex::new(None), + deadline: Instant::now() + CALL_TIMEOUT, + changed: Notify::new(), + }); + let state = app.state::(); + with_page_owner(&state, page_label, &pending.identity, || { + pending.check_scope(app)?; + state.functions.admit(pending.clone()) + })?; + if let Err(error) = app.emit_to( + &pending.identity.host_label, + "plugin-web-function-request", + json!({ + "operationId": pending.operation_id, + "call": pending.call, + "pageLabel": pending.page_label, + "pageGeneration": pending.identity.generation, + "hostLabel": pending.identity.host_label, + "pluginId": pending.identity.plugin_id, + "workspaceId": pending.identity.workspace_id, + "workspacePath": pending.identity.workspace_path, + "instance": pending.instance, + "function": pending.function, + "params": pending.params, + "list": pending.list, + }), + ) { + state.functions.remove(&pending.operation_id); + return Err(bridge_error( + "plugin.function.unavailable", + error.to_string(), + )); + } + let outcome = wait_for_result(app, pending.clone()).await; + pending.cancel(); + // Dismiss an approval even when native timeout or page revocation wins. + let _ = app.emit_to( + &pending.identity.host_label, + "plugin-web-function-cancelled", + json!({"operationId": pending.operation_id}), + ); + if outcome.is_err() { + if let Ok(entry) = + super::operation(app, &pending.identity.host_label, &pending.operation_id) + { + entry.cancel(); + if let Err(error) = super::runtime::cleanup(&entry).await { + let _ = app.emit_to( + &pending.identity.host_label, + "plugin-web-function-cleanup-failed", + json!({ "operationId": pending.operation_id, "context": entry.review.context, "error": error }), + ); + } else { + super::remove_clean(app, &entry); + } + } + } + state.functions.remove(&pending.operation_id); + outcome +} + +async fn wait_for_result(app: &AppHandle, pending: Arc) -> Result { + let mut last_progress = Value::Null; + loop { + pending.check(app)?; + if let Ok(entry) = + super::operation(app, &pending.identity.host_label, &pending.operation_id) + { + let snapshot = entry.snapshot(); + if let (Some(call), Some(progress)) = (&pending.call, snapshot.progress.clone()) { + if progress != last_progress { + pending.check(app)?; + if let Some(page) = app.get_webview_window(&pending.page_label) { + let payload = json!({ + "call": call, + "instance": pending.instance, + "function": pending.function, + "message": progress["message"], + "percentage": progress["percentage"], + }); + page.eval(format!( + "if(window.__lithePageGeneration==={})window.__litheDeliverEvent?.('functions.progress',{});", + pending.identity.generation, + payload + )) + .map_err(|error| error.to_string())?; + } + last_progress = progress; + } + } + if matches!( + snapshot.phase, + Phase::Succeeded | Phase::Failed | Phase::Cancelled | Phase::CleanupFailed + ) { + pending.verify_package(app)?; + let cleanup = super::runtime::cleanup(&entry).await; + super::remove_clean(app, &entry); + cleanup.map_err(|error| serde_json::to_string(&error).expect("function error"))?; + if snapshot.phase == Phase::Succeeded { + let result = snapshot.result.unwrap_or(Value::Null); + if serde_json::to_vec(&result) + .map_err(|_| "invalid result".to_string())? + .len() + > MAX_RESULT_BYTES + { + return Err(bridge_error( + "plugin.function.resultTooLarge", + "result is too large", + )); + } + return Ok(page_result(pending.list, result)); + } + if snapshot.phase == Phase::Cancelled { + return Err(bridge_error("requestCancelled", "requestCancelled")); + } + return Err(serde_json::to_string( + &snapshot + .error + .unwrap_or_else(|| Error::new("plugin.function.failed")), + ) + .expect("function error")); + } + } + tokio::select! { + _ = pending.changed.notified() => {}, + _ = tokio::time::sleep(Duration::from_millis(100)) => {}, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn identity(page: &str, generation: u64) -> BridgeIdentity { + BridgeIdentity { + generation, + workspace_generation: 1, + plugin_id: "example.plugin".into(), + window_id: page.into(), + host_label: "main".into(), + workspace_id: "workspace".into(), + workspace_path: "C:/fixture/project".into(), + state_id: page.into(), + } + } + + fn pending(page: &str, generation: u64, call: &str) -> Arc { + Arc::new(Pending { + operation_id: format!("op-{page}-{call}"), + call: Some(call.into()), + page_label: page.into(), + identity: identity(page, generation), + package_identity: "package-bytes".into(), + scope_revision: 0, + instance: "mix@root".into(), + function: Some("snapshot.refresh".into()), + params: json!({}), + list: false, + cancelled: AtomicBool::new(false), + failure: Mutex::new(None), + deadline: Instant::now() + CALL_TIMEOUT, + changed: Notify::new(), + }) + } + + #[test] + fn page_call_ids_are_unique_within_one_page_incarnation() { + let calls = Calls::default(); + calls.admit(pending("page", 1, "call")).unwrap(); + assert!(calls.admit(pending("page", 1, "call")).is_err()); + assert!(calls.admit(pending("page", 2, "call")).is_ok()); + } + + #[test] + fn page_revocation_cancels_calls_and_releases_call_id_budget() { + let calls = Calls::default(); + let entry = pending("page", 1, "call"); + calls.admit(entry.clone()).unwrap(); + calls.revoke_pages(&["page".into()]); + assert!(entry.cancelled.load(Ordering::Acquire)); + assert!(calls.admit(pending("page", 2, "call")).is_ok()); + } + + #[test] + fn cancel_while_waiting_for_approval_makes_late_prepare_inert() { + let calls = Calls::default(); + let entry = pending("page", 1, "waiting"); + calls.admit(entry.clone()).unwrap(); + calls.cancel_call("page", 1, "waiting"); + assert!(!calls.active(&entry.operation_id, "page")); + assert!(entry.cancelled.load(Ordering::Acquire)); + } + + #[test] + fn project_settings_reload_cancels_only_its_plugin_window_and_workspace_calls() { + let calls = Calls::default(); + let target = pending("target", 1, "call"); + let mut other_plugin = pending("other-plugin", 1, "call"); + Arc::get_mut(&mut other_plugin).unwrap().identity.plugin_id = "other.plugin".into(); + let mut other_window = pending("other-window", 1, "call"); + Arc::get_mut(&mut other_window).unwrap().identity.host_label = "workspace-other".into(); + let mut other_workspace = pending("other-workspace", 1, "call"); + Arc::get_mut(&mut other_workspace) + .unwrap() + .identity + .workspace_path = "C:/fixture/other".into(); + for entry in [&target, &other_plugin, &other_window, &other_workspace] { + calls.admit(entry.clone()).unwrap(); + } + calls.revoke_scope("main", "C:/fixture/project", "example.plugin"); + assert!(!calls.active(&target.operation_id, &target.page_label)); + for entry in [&other_plugin, &other_window, &other_workspace] { + assert!(calls.active(&entry.operation_id, &entry.page_label)); + } + assert!( + calls.admit(pending("target", 1, "call")).is_err(), + "reload must not re-enable a used call ID in the same page incarnation" + ); + } + + #[test] + fn per_page_concurrency_is_bounded_without_affecting_another_page() { + let calls = Calls::default(); + for index in 0..MAX_PAGE_CALLS { + calls + .admit(pending("page", 1, &format!("call-{index}"))) + .unwrap(); + } + assert!(calls.admit(pending("page", 1, "overflow")).is_err()); + assert!(calls.admit(pending("other-page", 1, "call")).is_ok()); + } + + #[test] + fn host_approval_resolves_the_registered_page_and_refusal_finishes_before_prepare() { + let calls = Calls::default(); + let entry = pending("plugin-page", 4, "call"); + calls.admit(entry.clone()).unwrap(); + assert!(calls.for_host(&entry.operation_id, "main").is_some()); + assert!(calls.for_host(&entry.operation_id, "plugin-page").is_none()); + assert!(!calls.cancel_for_host(&entry.operation_id, "other-workbench", None)); + assert!(entry.check_outcome().is_ok()); + let error = Error { + code: "plugin.function.failed".into(), + message: "backend rejected".into(), + plugin_error_code: Some("fixture.refused".into()), + }; + assert!(calls.cancel_for_host(&entry.operation_id, "main", Some(error))); + let result: Value = serde_json::from_str(&entry.check_outcome().unwrap_err()).unwrap(); + assert_eq!(result["code"], "plugin.function.failed"); + assert_eq!(result["pluginErrorCode"], "fixture.refused"); + assert!(!calls.active(&entry.operation_id, "plugin-page")); + } + + #[test] + fn page_success_uses_the_shared_list_and_invoke_envelopes() { + assert_eq!( + page_result(true, json!(["snapshot.refresh"])), + json!({"functions":["snapshot.refresh"]}) + ); + assert_eq!( + page_result(false, json!({"answer":42})), + json!({"value":{"answer":42}}) + ); + assert_eq!(page_result(false, Value::Null), json!({"value":null})); + } + + #[test] + fn the_first_cancellation_or_refusal_keeps_its_terminal_code() { + let calls = Calls::default(); + let cancelled = pending("page", 1, "cancel-first"); + calls.admit(cancelled.clone()).unwrap(); + calls.cancel_call("page", 1, "cancel-first"); + let refusal = Error { + code: "plugin.function.failed".into(), + message: "backend refused".into(), + plugin_error_code: Some("fixture.refused".into()), + }; + calls.cancel_for_host(&cancelled.operation_id, "main", Some(refusal.clone())); + let outcome: Value = serde_json::from_str(&cancelled.check_outcome().unwrap_err()).unwrap(); + assert_eq!(outcome["code"], "requestCancelled"); + + let refused = pending("page", 1, "refusal-first"); + calls.admit(refused.clone()).unwrap(); + calls.cancel_for_host(&refused.operation_id, "main", Some(refusal)); + calls.cancel_call("page", 1, "refusal-first"); + let outcome: Value = serde_json::from_str(&refused.check_outcome().unwrap_err()).unwrap(); + assert_eq!(outcome["code"], "plugin.function.failed"); + assert_eq!(outcome["pluginErrorCode"], "fixture.refused"); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/install.rs b/windows/tauri/src-tauri/src/plugin_host/install.rs index c691c98275..dad8f95726 100644 --- a/windows/tauri/src-tauri/src/plugin_host/install.rs +++ b/windows/tauri/src-tauri/src/plugin_host/install.rs @@ -6,14 +6,16 @@ //! the input is copied to a private snapshot first, Core's //! `plugins.marketplace.installReview` produces the permission and program //! summary the user confirms, and installation re-validates the staged bytes -//! before they replace the active version. Package trust comes only from this -//! explicit review; there is no signature verification in v0 (recorded gap in -//! the declarative plugin host note). +//! before they replace the active version. Disk packages are community +//! origin: the installation record stores the content digest and the +//! approved programs of the confirmed review, and every later load rechecks +//! them. A publisher signature document is verified whenever one is present; +//! it never substitutes for the user's review. use super::scan::{core_execute, validate_manifest}; -use super::store::{self, InstallationRecord}; +use super::store::{self, DiskProvenance, InstallationRecord}; use super::{CatalogEntry, PluginHostState}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::io::Read; use std::path::{Path, PathBuf}; @@ -55,7 +57,7 @@ impl PackageBudget { } /// A prepared disk import awaiting user confirmation. -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct PreparedInstall { pub session_id: String, @@ -65,10 +67,13 @@ pub struct PreparedInstall { pub manifest_digest: String, /// Human-readable display name from the manifest. pub display_name: String, + /// Host policy for explicit consent to the reviewed permissions and programs. + #[serde(skip)] + pub(crate) approval_required: bool, } /// Core's install review, reshaped for the frontend. -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct InstallReview { pub plugin_id: String, @@ -78,6 +83,89 @@ pub struct InstallReview { pub new_programs: Vec, } +/// The evidence frozen when the user's review was prepared, recorded beside +/// the snapshot: what the user installed, and exactly which bytes and which +/// review identity prepare showed. Confirm and staging must reach the same +/// answers, so nothing can change between review and install. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SnapshotSource { + file_name: String, + archive_sha256: Option, + /// Digest of the reviewed `plugin.json`. + manifest_digest: String, + /// Whole-package content digest of the reviewed snapshot. + content_digest: String, + /// Canonical JSON programs of the prepare-time review. + approved_programs: Vec, + #[serde(default)] + marketplace: Option, + #[serde(default)] + previous_approval: Option>, + #[serde(default)] + previous_record_digest: Option, +} + +/// Host-derived source and Core-selected archive identity. It never comes from +/// an IPC payload: the coordinator resolves it from the current source and index. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MarketplaceInstallSource { + pub source_id: String, + pub source_name: String, + pub index_url: String, + pub repository: String, + pub archive_url: String, + pub archive_sha256: String, + pub plugin_id: String, + pub version: String, + pub official: bool, +} + +/// Reusing an approval requires the exact original source. Disk, unknown and +/// malformed records cannot grant a marketplace permission to take over an ID. +pub(crate) fn marketplace_previous_record( + install_root: &Path, + source: &MarketplaceInstallSource, +) -> Result<(Option, Option), String> { + let bytes = match std::fs::read( + install_root + .join(&source.plugin_id) + .join("installation.json"), + ) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if install_root.join(&source.plugin_id).exists() { + return Err("the existing installation has no provenance; it cannot be adopted by a marketplace source".into()); + } + return Ok((None, None)); + } + Err(error) => return Err(format!("the installed source cannot be read: {error}")), + }; + let record: InstallationRecord = serde_json::from_slice(&bytes) + .map_err(|error| format!("the installed source cannot be read: {error}"))?; + let provenance = record + .marketplace + .as_ref() + .ok_or("disk installations cannot be taken over by a marketplace source")?; + if record.schema_version != 2 + || record.plugin_id != source.plugin_id + || record.disk.is_some() + || provenance.source_id != source.source_id + || provenance.index_url != source.index_url + || !matches!( + record.origin, + Some( + super::signature::InstallOrigin::Community + | super::signature::InstallOrigin::Marketplace + ) + ) + { + return Err("marketplace updates must use the original installation source".into()); + } + Ok((Some(record), Some(format!("{:x}", Sha256::digest(&bytes))))) +} + /// Prepares a disk import from a ZIP archive or an unpacked directory. The /// source is copied to a private snapshot under the app's temporary /// directory; nothing is installed and nothing outside the snapshot is @@ -87,16 +175,62 @@ pub fn declarative_plugins_prepare_disk_install( app: AppHandle, source_path: String, ) -> Result { - let temporary_root = app - .path() - .app_local_data_dir() - .map_err(|error| error.to_string())? - .join("plugin-temp"); + let temporary_root = snapshot_root(&app)?; std::fs::create_dir_all(&temporary_root).map_err(|error| error.to_string())?; let session_id = format!("{}{}", SNAPSHOT_PREFIX, uuid_like_suffix()); - let snapshot = temporary_root.join(&session_id); - let source = PathBuf::from(&source_path); + prepare_disk_install_snapshot(&temporary_root, &session_id, &source_path) +} + +/// The testable core of the prepare command: snapshot the source, review it, +/// and park the session under `temporary_root`. A failed prepare discards +/// its snapshot and session record. +pub(crate) fn prepare_disk_install_snapshot( + temporary_root: &Path, + session_id: &str, + source_path: &str, +) -> Result { + prepare_install_snapshot(temporary_root, session_id, source_path, None) +} + +pub(crate) fn prepare_marketplace_install_snapshot( + install_root: &Path, + temporary_root: &Path, + session_id: &str, + archive: &Path, + source: &MarketplaceInstallSource, +) -> Result { + let previous = marketplace_previous_record(install_root, source)?; + prepare_install_snapshot( + temporary_root, + session_id, + archive.to_str().ok_or("the archive path is not UTF-8")?, + Some((source, previous)), + ) +} + +fn prepare_install_snapshot( + temporary_root: &Path, + session_id: &str, + source_path: &str, + marketplace: Option<( + &MarketplaceInstallSource, + (Option, Option), + )>, +) -> Result { + let snapshot = validated_session_path(temporary_root, session_id)?; + std::fs::create_dir_all(temporary_root).map_err(|error| error.to_string())?; + let source = PathBuf::from(source_path); let result = (|| { + let file_name = source + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .ok_or("the source has no file name")?; + let archive_sha256 = + if source.extension().and_then(|extension| extension.to_str()) == Some("zip") { + Some(archive_digest(&source)?) + } else { + None + }; if source.extension().and_then(|extension| extension.to_str()) == Some("zip") { extract_zip_snapshot(&source, &snapshot)?; } else if source.is_dir() { @@ -107,11 +241,92 @@ pub fn declarative_plugins_prepare_disk_install( source.display() )); } - let manifest = read_snapshot_manifest(&snapshot)?; + if let Some(expected) = archive_sha256.as_deref() { + if archive_digest(&source)? != expected { + return Err("the archive changed while preparing its private snapshot".into()); + } + } + let manifest = read_package_manifest(&snapshot)?; + let plugin_id = manifest["id"] + .as_str() + .ok_or("manifest has no id")? + .to_string(); + let version = manifest["version"] + .as_str() + .ok_or("manifest has no version")? + .to_string(); + // Trust is verified before the review is offered: a signature + // document must already verify here, and an unsigned package must be + // community-installable, so the review cannot be offered for bytes + // the host would refuse anyway. + let market_source = marketplace.as_ref().map(|(source, _)| *source); + if let Some(source) = market_source { + if plugin_id != source.plugin_id + || version != source.version + || archive_sha256.as_deref() != Some(source.archive_sha256.as_str()) + { + return Err( + "the downloaded package does not match its Core-selected archive".into(), + ); + } + } + super::signature::verify_for_install( + &snapshot, + &plugin_id, + &version, + &manifest, + marketplace_origin(market_source), + None, + &super::signature::trusted_keys(), + ) + .map_err(|error| error.message())?; + // The whole-package digest is frozen now: confirm and staging bind + // the install to exactly these bytes. + let frozen_digest = + super::signature::content_digest(&snapshot).map_err(|error| error.message())?; + let previous_approval = marketplace + .as_ref() + .and_then(|(_, (record, _))| record.as_ref()) + .and_then(|record| record.marketplace.as_ref()) + .map(|provenance| { + provenance + .approved_programs + .iter() + .map(|program| serde_json::from_str(program).map_err(|error| error.to_string())) + .collect::, String>>() + }) + .transpose()?; + let review = + review_snapshot_with_source(&manifest, previous_approval.as_deref(), market_source)?; + // Existing provenance records program approvals, not permission grants. + // An unchanged program list cannot prove that new permissions (such as + // ui.web) were approved. Require explicit consent for every snapshot + // until the approval contract can preserve both facts together. + let approval_required = true; + // Source facts live beside the snapshot, not inside it: every file + // under the snapshot becomes an installed package file. + std::fs::write( + snapshot_source_path(temporary_root, session_id), + serde_json::to_string(&SnapshotSource { + file_name, + archive_sha256, + manifest_digest: manifest_replay_digest(&manifest), + content_digest: frozen_digest, + approved_programs: canonical_programs(&review.programs), + marketplace: market_source.cloned(), + previous_approval, + previous_record_digest: marketplace + .as_ref() + .and_then(|(_, (_, digest))| digest.clone()), + }) + .map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; Ok(PreparedInstall { - session_id, - review: review_snapshot(&manifest, None)?, - manifest_digest: content_digest(&manifest), + approval_required, + session_id: session_id.to_string(), + review, + manifest_digest: manifest_replay_digest(&manifest), display_name: manifest["displayName"] .as_str() .unwrap_or("plugin") @@ -120,6 +335,7 @@ pub fn declarative_plugins_prepare_disk_install( })(); if result.is_err() { discard_snapshot(&snapshot); + discard_source_metadata(temporary_root, session_id); } result } @@ -127,65 +343,398 @@ pub fn declarative_plugins_prepare_disk_install( /// Installs a previously prepared snapshot after the user confirmed the /// review. The confirm call re-validates the snapshot bytes and re-runs the /// review, so a snapshot swapped between prepare and confirm is rejected. +/// The staged copy is re-verified against the recorded content digest before +/// it becomes the active version, and the installation record is written in +/// the same transaction as the version swap: a failure at any point leaves +/// the previous version and its record untouched. #[tauri::command] pub async fn declarative_plugins_confirm_disk_install( app: AppHandle, session_id: String, manifest_digest: String, ) -> Result { + let temporary_root = snapshot_root(&app)?; + let install_root = store::install_root(&app)?; let snapshot = snapshot_path(&app, &session_id)?; let result = async { - let manifest = read_snapshot_manifest(&snapshot)?; - if content_digest(&manifest) != manifest_digest { - return Err("the package changed after review; the import was cancelled".to_string()); - } - // Re-review with no previously approved programs: a disk install always - // requires a fresh full confirmation, matching the macOS rule. - let _ = review_snapshot(&manifest, None)?; - - let install_root = store::install_root(&app)?; - let plugin_id = manifest["id"] - .as_str() - .ok_or("manifest has no id")? - .to_string(); - let version = manifest["version"] - .as_str() - .ok_or("manifest has no version")? - .to_string(); - - // Validate the final manifest against this host's capabilities before - // anything is written into the install root. - validate_manifest(&manifest).map_err(|rejection| rejection.message)?; - + // The session identity is resolved once, inside the cleanup-guarded + // block: a malformed session fails into the same release path as + // everything else. This one value keys the real lifecycle barrier + // and is passed to the orchestration as the expected identity, so + // the pre-publish check compares against the barrier actually held. + let session_plugin = session_plugin_id(&temporary_root, &session_id)?; // A version switch invalidates the page's package bytes and bridge // authorization, so destroy all owned webviews before staging starts. - let mutation = super::lifecycle::begin(&app, &plugin_id).await?; - staging_install(&install_root, &plugin_id, &version, &snapshot)?; - write_installation_record(&install_root, &plugin_id, &version)?; + let mutation = super::lifecycle::begin(&app, &session_plugin).await?; + // Inside the barrier: the session is re-verified and the install + // record is read and built from the record state as it exists now, + // so another mutation that committed during the drain is not + // overwritten with a stale rollback chain. + let confirmed = confirm_and_publish_in_barrier( + &install_root, + &temporary_root, + &session_id, + &manifest_digest, + &snapshot, + &session_plugin, + &|| Ok(()), + )?; rescan_after_change(&app)?; mutation.complete(true); - Ok(catalog_entry_of(&app, &plugin_id) - .unwrap_or_else(|| accepted_entry_fallback(&plugin_id, &version))) + Ok(catalog_entry_of(&app, &confirmed.plugin_id) + .unwrap_or_else(|| accepted_entry_fallback(&confirmed.plugin_id, &confirmed.version))) } .await; - discard_snapshot(&snapshot); + // Every way out of the command - success, validation refusal, barrier + // failure - releases the snapshot and the session record. + discard_install_session(&temporary_root, &session_id, &snapshot); + result +} + +/// Cheap pre-barrier read of the session's plugin identity. +#[cfg(test)] +pub(crate) fn session_plugin_id_for_tests( + temporary_root: &Path, + session_id: &str, +) -> Result { + session_plugin_id(temporary_root, session_id) +} + +fn session_plugin_id(temporary_root: &Path, session_id: &str) -> Result { + let snapshot = validated_session_path(temporary_root, session_id)?; + let manifest = read_package_manifest(&snapshot)?; + manifest["id"] + .as_str() + .map(str::to_string) + .ok_or_else(|| "the install session has no plugin id".to_string()) +} + +/// Releases the snapshot directory and the session record of one review. +#[cfg(test)] +pub(crate) fn discard_install_session_for_tests( + temporary_root: &Path, + session_id: &str, + snapshot: &Path, +) { + discard_install_session(temporary_root, session_id, snapshot) +} + +pub(crate) fn discard_install_session(temporary_root: &Path, session_id: &str, snapshot: &Path) { + discard_snapshot(snapshot); + discard_source_metadata(temporary_root, session_id); +} + +/// Runs the barrier-guarded section of a disk-install confirm, the same +/// sequence the command drives. `expected_plugin_id` is the identity the +/// caller resolved when it took its real barrier — the command awaits +/// `lifecycle::begin` for exactly this ID and passes it here, so the check +/// below proves the published bytes belong to the plugin whose barrier is +/// actually held. The session is re-verified and the install record is read +/// and built from the record state as it exists now, the identity is +/// re-checked before anything is published, and only then are the staged +/// bytes published. `begin` is injected so tests drive the same sequence +/// without an AppHandle; the real command passes a no-op because its barrier +/// is already held. A failed section releases the snapshot and session +/// record: the session cannot be retried through this call. +pub(crate) fn confirm_and_publish_in_barrier( + install_root: &Path, + temporary_root: &Path, + session_id: &str, + manifest_digest: &str, + snapshot: &Path, + expected_plugin_id: &str, + begin: &dyn Fn() -> Result<(), String>, +) -> Result { + let result = (|| { + begin()?; + let confirmed = confirm_disk_install_snapshot( + install_root, + temporary_root, + session_id, + manifest_digest, + )?; + // The identity must still be the one the barrier was taken for: + // publishing under another plugin's barrier would bypass its stop + // guarantees, so this check happens before any bytes move. The + // expected ID comes from the caller's own barrier acquisition, never + // from a second read of the session. + if confirmed.plugin_id != expected_plugin_id { + return Err(format!( + "the install session changed during confirmation; it now installs {} but the barrier was taken for {expected_plugin_id}", + confirmed.plugin_id + )); + } + publish_installed_version( + install_root, + &confirmed.plugin_id, + &confirmed.version, + snapshot, + confirmed.record.clone(), + )?; + Ok(confirmed) + })(); + if result.is_err() { + discard_install_session(temporary_root, session_id, snapshot); + } result } +/// The outcome of a confirmed review: everything the publish transaction +/// needs, validated against the snapshot as it existed at confirm time. +#[derive(Debug)] +pub(crate) struct ConfirmedDiskInstall { + pub plugin_id: String, + pub version: String, + pub record: InstallationRecord, +} + +/// The testable core of the confirm command: re-review the snapshot, verify +/// its trust, and build the installation record. Publishing happens later, +/// behind the lifecycle barrier, from the same validated record. +pub(crate) fn confirm_disk_install_snapshot( + install_root: &Path, + temporary_root: &Path, + session_id: &str, + manifest_digest: &str, +) -> Result { + confirm_install_snapshot( + install_root, + temporary_root, + session_id, + manifest_digest, + None, + ) +} + +pub(crate) fn confirm_marketplace_install_snapshot( + install_root: &Path, + temporary_root: &Path, + session_id: &str, + manifest_digest: &str, + expected: &MarketplaceInstallSource, +) -> Result { + confirm_install_snapshot( + install_root, + temporary_root, + session_id, + manifest_digest, + Some(expected), + ) +} + +fn marketplace_origin( + source: Option<&MarketplaceInstallSource>, +) -> super::signature::InstallOrigin { + if source.is_some_and(|source| source.official) { + super::signature::InstallOrigin::Marketplace + } else { + super::signature::InstallOrigin::Community + } +} + +fn confirm_install_snapshot( + install_root: &Path, + temporary_root: &Path, + session_id: &str, + manifest_digest: &str, + expected_marketplace: Option<&MarketplaceInstallSource>, +) -> Result { + let snapshot = validated_session_path(temporary_root, session_id)?; + let source: SnapshotSource = serde_json::from_str( + &std::fs::read_to_string(snapshot_source_path(temporary_root, session_id)) + .map_err(|error| format!("the install session has no source record: {error}"))?, + ) + .map_err(|error| format!("the install session source record is unreadable: {error}"))?; + if source.marketplace.as_ref() != expected_marketplace { + return Err("the install channel or marketplace selection changed after review".into()); + } + if let Some(expected) = expected_marketplace { + let (_, digest) = marketplace_previous_record(install_root, expected)?; + if digest != source.previous_record_digest { + return Err( + "the installed version or approvals changed; prepare a new marketplace review" + .into(), + ); + } + } + let manifest = read_package_manifest(&snapshot)?; + // The caller echoes the manifest digest it reviewed; the session record + // freezes it independently, so a tampered caller cannot bless new bytes. + if manifest_replay_digest(&manifest) != manifest_digest + || manifest_replay_digest(&manifest) != source.manifest_digest + { + return Err("the package changed after review; the import was cancelled".to_string()); + } + // The whole package must still be exactly the bytes prepare froze: any + // replaced, added, or removed file is a rejection before anything runs. + let package_digest = + super::signature::content_digest(&snapshot).map_err(|error| error.message())?; + if package_digest != source.content_digest { + return Err("the package changed after review; the import was cancelled".to_string()); + } + // Disk always reviews every program afresh. Marketplace reviews compare the + // same original-source approval that prepare froze; the durable record must + // still match before those approvals can be reused. + let review = review_snapshot_with_source( + &manifest, + source.previous_approval.as_deref(), + expected_marketplace, + )?; + if canonical_programs(&review.programs) != source.approved_programs { + return Err( + "the reviewed programs changed after the package was prepared; the import was \ + cancelled" + .to_string(), + ); + } + let plugin_id = manifest["id"] + .as_str() + .ok_or("manifest has no id")? + .to_string(); + let version = manifest["version"] + .as_str() + .ok_or("manifest has no version")? + .to_string(); + + if let Some(expected) = expected_marketplace { + if plugin_id != expected.plugin_id || version != expected.version { + return Err("the reviewed package no longer matches the marketplace selection".into()); + } + } + // Validate the final manifest against this host's capabilities before + // anything is written into the install root. + validate_manifest(&manifest).map_err(|rejection| rejection.message)?; + // The pinned trust policy still + // applies (an official ID cannot install as an unsigned community + // package), and a signature document present in the package must + // verify even though the community digest is the recorded evidence. + super::signature::verify_for_install( + &snapshot, + &plugin_id, + &version, + &manifest, + marketplace_origin(expected_marketplace), + None, + &super::signature::trusted_keys(), + ) + .map_err(|error| error.message())?; + + let existing = read_installation_record(install_root, &plugin_id); + let replaces_active = existing + .as_ref() + .map(|record| record.version == version) + .unwrap_or(false); + // The same-version rule keeps the previous chain intact: replacing + // the active version in place does not overwrite what the user can + // still roll back to. + let (previous_version, previous_origin) = if replaces_active { + ( + existing + .as_ref() + .and_then(|record| record.previous_version.clone()), + existing.as_ref().and_then(|record| record.previous_origin), + ) + } else { + ( + existing.as_ref().map(|record| record.version.clone()), + existing.as_ref().and_then(|record| record.origin), + ) + }; + let (previous_marketplace, previous_disk) = if replaces_active { + ( + existing + .as_ref() + .and_then(|record| record.previous_marketplace.clone()), + existing + .as_ref() + .and_then(|record| record.previous_disk.clone()), + ) + } else { + ( + existing + .as_ref() + .and_then(|record| record.marketplace.clone()), + existing.as_ref().and_then(|record| record.disk.clone()), + ) + }; + let record = InstallationRecord { + schema_version: 2, + plugin_id: plugin_id.clone(), + version: version.clone(), + previous_version, + enabled: if expected_marketplace.is_some() { + existing + .as_ref() + .map(|record| record.enabled) + .unwrap_or(true) + } else { + true + }, + origin: Some(marketplace_origin(expected_marketplace)), + previous_origin, + marketplace: expected_marketplace.map(|market| store::MarketplaceProvenance { + source_id: market.source_id.clone(), + source_name: market.source_name.clone(), + repository: market.repository.clone(), + index_url: market.index_url.clone(), + archive_url: market.archive_url.clone(), + archive_sha256: market.archive_sha256.clone(), + content_digest: package_digest.clone(), + approved_programs: source.approved_programs.clone(), + }), + previous_marketplace, + disk: expected_marketplace.is_none().then(|| DiskProvenance { + file_name: source.file_name, + archive_sha256: source.archive_sha256, + // The approved bytes are the ones prepare froze, re-checked above. + content_digest: package_digest, + approved_programs: source.approved_programs, + }), + previous_disk, + }; + Ok(ConfirmedDiskInstall { + plugin_id, + version, + record, + }) +} + /// Abandons an installation review and removes its private snapshot. #[tauri::command] pub fn declarative_plugins_cancel_disk_install( app: AppHandle, session_id: String, ) -> Result<(), String> { + let temporary_root = snapshot_root(&app)?; let snapshot = snapshot_path(&app, &session_id)?; if snapshot.exists() { std::fs::remove_dir_all(snapshot).map_err(|error| error.to_string())?; } + discard_source_metadata(&temporary_root, &session_id); Ok(()) } fn snapshot_path(app: &AppHandle, session_id: &str) -> Result { + validated_session_path(&snapshot_root(&app)?, session_id) +} + +pub(crate) fn snapshot_root(app: &AppHandle) -> Result { + Ok(app + .path() + .app_local_data_dir() + .map_err(|error| error.to_string())? + .join("plugin-temp")) +} + +fn snapshot_source_path(temporary_root: &Path, session_id: &str) -> PathBuf { + temporary_root.join(format!("{session_id}.source.json")) +} + +/// Resolves and validates the snapshot directory of one review session. +pub(crate) fn validated_session_path( + temporary_root: &Path, + session_id: &str, +) -> Result { if !session_id.starts_with(SNAPSHOT_PREFIX) || session_id.contains('/') || session_id.contains('\\') @@ -193,12 +742,11 @@ fn snapshot_path(app: &AppHandle, session_id: &str) -> Result { { return Err("invalid install session".to_string()); } - Ok(app - .path() - .app_local_data_dir() - .map_err(|error| error.to_string())? - .join("plugin-temp") - .join(session_id)) + Ok(temporary_root.join(session_id)) +} + +fn discard_source_metadata(temporary_root: &Path, session_id: &str) { + let _ = std::fs::remove_file(snapshot_source_path(temporary_root, session_id)); } /// Removes an installed package. Bundled packages cannot be uninstalled. @@ -207,6 +755,7 @@ pub async fn declarative_plugins_uninstall( app: AppHandle, plugin_id: String, ) -> Result<(), String> { + super::marketplace::cancel_plugin(&app, &plugin_id); let install_root = store::install_root(&app)?; let plugin_directory = install_root.join(&plugin_id); if !plugin_directory.is_dir() { @@ -233,6 +782,7 @@ pub async fn declarative_plugins_set_enabled( plugin_id: String, enabled: bool, ) -> Result<(), String> { + super::marketplace::cancel_plugin(&app, &plugin_id); let install_root = store::install_root(&app)?; let record_path = install_root.join(&plugin_id).join("installation.json"); let mut record: InstallationRecord = serde_json::from_str( @@ -255,11 +805,16 @@ pub async fn declarative_plugins_set_enabled( Ok(()) } -/// Restores the previous version directory of an installed package. Rollback -/// requires a retained previous version and always re-enables the package so -/// a broken update cannot leave it disabled and forgotten. +/// Restores the previous version of an installed package. The restored +/// package is verified with its own recorded provenance — a community +/// version is rechecked against the digest approved for it, and a signature +/// document present in it must still verify — before the record swaps, so a +/// rollback can never activate bytes the host would refuse to load. +/// Rollback requires a retained previous version and always re-enables the +/// package so a broken update cannot leave it disabled and forgotten. #[tauri::command] pub async fn declarative_plugins_rollback(app: AppHandle, plugin_id: String) -> Result<(), String> { + super::marketplace::cancel_plugin(&app, &plugin_id); let install_root = store::install_root(&app)?; let plugin_directory = install_root.join(&plugin_id); let record: InstallationRecord = serde_json::from_str( @@ -267,33 +822,39 @@ pub async fn declarative_plugins_rollback(app: AppHandle, plugin_id: String) -> .map_err(|error| format!("{plugin_id} has no readable install record: {error}"))?, ) .map_err(|error| error.to_string())?; - let mut versions: Vec = std::fs::read_dir(&plugin_directory) - .map_err(|error| error.to_string())? - .flatten() - .filter_map(|entry| { - let name = entry.file_name().to_string_lossy().to_string(); - let path = entry.path(); - (path.is_dir() && path.join("plugin.json").is_file()).then_some(name) - }) - .collect(); - versions.sort_by(|left, right| store::compare_versions(left, right)); - versions.reverse(); - let Some(previous) = versions - .into_iter() - .find(|version| *version != record.version) - else { + let Some(previous) = record.previous_version.clone() else { return Err(format!( "{plugin_id} has no previous version to roll back to" )); }; - let mutation = super::lifecycle::begin(&app, &plugin_id).await?; - staging_install( - &install_root, + let Some(previous_origin) = record.previous_origin.or(record.origin) else { + return Err(format!( + "{plugin_id} has no recorded origin to verify the previous version with" + )); + }; + let previous_directory = plugin_directory.join(&previous); + verify_rollback_candidate( + &previous_directory, &plugin_id, &previous, - &plugin_directory.join(&previous), + previous_origin, + record.previous_content_digest(), )?; - write_installation_record(&install_root, &plugin_id, &previous)?; + let mutation = super::lifecycle::begin(&app, &plugin_id).await?; + let swapped = InstallationRecord { + schema_version: 2, + plugin_id: record.plugin_id.clone(), + version: previous, + previous_version: Some(record.version.clone()), + enabled: true, + origin: record.previous_origin, + previous_origin: record.origin, + marketplace: record.previous_marketplace.clone(), + previous_marketplace: record.marketplace.clone(), + disk: record.previous_disk.clone(), + previous_disk: record.disk.clone(), + }; + write_installation_record(&install_root, &swapped)?; rescan_after_change(&app)?; mutation.complete(true); Ok(()) @@ -301,9 +862,10 @@ pub async fn declarative_plugins_rollback(app: AppHandle, plugin_id: String) -> // MARK: helpers -fn review_snapshot( +fn review_snapshot_with_source( manifest: &serde_json::Value, approved_programs: Option<&[serde_json::Value]>, + marketplace: Option<&MarketplaceInstallSource>, ) -> Result { let outcome = core_execute( "plugins.marketplace.installReview", @@ -311,6 +873,8 @@ fn review_snapshot( "manifest": manifest, "hostCapabilities": super::host_capabilities(), "approvedPrograms": approved_programs, + "expectedID": marketplace.map(|source| source.plugin_id.as_str()), + "expectedVersion": marketplace.map(|source| source.version.as_str()), }), )?; if outcome["status"] == serde_json::json!("rejected") { @@ -332,92 +896,480 @@ fn review_snapshot( }) } -/// Copies the snapshot into the install root, then validates the copied -/// manifest. Staging first keeps a failed copy from destroying the current -/// version: the active version directory is only replaced after the staged -/// copy validates. -pub(crate) fn staging_install( +/// Publishes a validated package as the active version of an installed +/// plugin. The staged copy is verified against the recorded provenance +/// before it replaces the active version, and the installation record is +/// written in the same transaction: a failure at any point restores the +/// previous version directory and leaves the old record readable. +pub(crate) fn publish_installed_version( install_root: &Path, plugin_id: &str, version: &str, source: &Path, + record: InstallationRecord, +) -> Result<(), String> { + publish_installed_version_with_backup_cleanup( + install_root, + plugin_id, + version, + source, + record, + &|path| std::fs::remove_dir_all(path), + ) +} + +/// Uses the same publication transaction with a replaceable final cleanup +/// operation. Cleanup failure is deliberately non-fatal after the durable +/// record commits; injecting it lets tests retain the real backup without +/// relying on Windows file-attribute or antivirus timing behavior. +pub(crate) fn publish_installed_version_with_backup_cleanup( + install_root: &Path, + plugin_id: &str, + version: &str, + source: &Path, + record: InstallationRecord, + cleanup_backup: &dyn Fn(&Path) -> std::io::Result<()>, +) -> Result<(), String> { + publish_installed_version_using_commit( + install_root, + plugin_id, + version, + source, + record, + cleanup_backup, + &|activate| activate(), + ) +} + +/// Shares the complete M1 staging and trust transaction while allowing a native +/// owner to recheck cancellation/source ownership at the final activation boundary. +pub(crate) fn publish_installed_version_guarded( + install_root: &Path, + plugin_id: &str, + version: &str, + source: &Path, + record: InstallationRecord, + commit: &dyn Fn(&mut dyn FnMut() -> Result<(), String>) -> Result<(), String>, +) -> Result<(), String> { + publish_installed_version_using_commit( + install_root, + plugin_id, + version, + source, + record, + &|path| std::fs::remove_dir_all(path), + commit, + ) +} + +fn publish_installed_version_using_commit( + install_root: &Path, + plugin_id: &str, + version: &str, + source: &Path, + record: InstallationRecord, + cleanup_backup: &dyn Fn(&Path) -> std::io::Result<()>, + commit: &dyn Fn(&mut dyn FnMut() -> Result<(), String>) -> Result<(), String>, ) -> Result<(), String> { let plugin_directory = install_root.join(plugin_id); + let created_plugin_directory = !plugin_directory.exists(); std::fs::create_dir_all(&plugin_directory).map_err(|error| error.to_string())?; + // Leftover recovery backups of any version are judged before anything is + // staged, directed by the durable record's approved digest. + let stale_backups: Vec = std::fs::read_dir(&plugin_directory) + .map_err(|error| error.to_string())? + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.is_dir() + && path + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.ends_with(".previous-tmp")) + .unwrap_or(false) + }) + .collect(); + for backup_directory in stale_backups { + resolve_recovery_backup(install_root, plugin_id, &backup_directory)?; + } let version_directory = plugin_directory.join(version); let staging_directory = plugin_directory.join(format!(".staging-{version}")); if staging_directory.exists() { std::fs::remove_dir_all(&staging_directory).map_err(|error| error.to_string())?; } - copy_directory_snapshot(source, &staging_directory)?; - let result = staging_validate_and_activate(&staging_directory, &version_directory); + let result = (|| { + copy_directory_snapshot(source, &staging_directory)?; + // The staged bytes are what gets installed: re-validate the copy, + // then bind it to the digest recorded for the approved snapshot. + let staged_manifest = read_package_manifest(&staging_directory)?; + validate_manifest(&staged_manifest).map_err(|rejection| rejection.message)?; + let origin = record + .origin + .ok_or("the installation record has no origin")?; + super::signature::verify_for_install( + &staging_directory, + plugin_id, + version, + &staged_manifest, + origin, + None, + &super::signature::trusted_keys(), + ) + .map_err(|error| error.message())?; + if let Some(expected) = record.active_content_digest() { + let staged_digest = super::signature::content_digest(&staging_directory) + .map_err(|error| error.message())?; + if staged_digest != expected { + return Err(super::signature::TrustError::ContentChanged.message()); + } + } + commit(&mut || { + activate_staged_version_and_record( + &staging_directory, + &version_directory, + install_root, + version, + &record, + &|_| Ok(()), + ) + }) + })(); if result.is_err() { - // A failed install never leaves staging bytes behind. let _ = std::fs::remove_dir_all(&staging_directory); + if created_plugin_directory { + let _ = std::fs::remove_dir(&plugin_directory); + } + } else { + // Backup removal is best effort after the durable commit. Keep this + // potentially large IO outside the owner's short activation lock. + let _ = cleanup_backup(&plugin_directory.join(format!("{version}.previous-tmp"))); } result } -fn staging_validate_and_activate( +/// Replaces `version_directory` with the staged copy and commits the +/// installation record as one transaction. The record is staged to its temp +/// path before any version byte moves, so a record that cannot be written +/// fails while the old version is still intact; if the record commit fails +/// after the swap, the previous bytes are restored and both failures are +/// reported instead of leaving new bytes under the old record. +/// Judges a recovery backup left by an interrupted transaction, directed by +/// the durable installation record's approved digest: restore the backup +/// only when it provably holds the approved bytes, remove it only when the +/// approved bytes are provably intact elsewhere, and refuse an ambiguous +/// state with every byte kept. Restore failures are reported, never +/// swallowed. +fn resolve_recovery_backup( + install_root: &Path, + plugin_id: &str, + backup_directory: &Path, +) -> Result<(), String> { + let refuse = |message: String| -> Result<(), String> { Err(message) }; + let backup_version = backup_directory + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_suffix(".previous-tmp")) + .ok_or("a recovery backup without a version name")? + .to_string(); + let durable = match read_installation_record(install_root, plugin_id) { + Some(record) if record.schema_version == 2 => record, + _ => { + return refuse( + "a recovery backup exists and the installation record cannot prove which bytes \ + are approved; the backup and any interrupted bytes were kept" + .to_string(), + ); + } + }; + // A user-writable record cannot choose another plugin or a path outside + // its version directory while recovery moves package bytes. + let is_version_component = |value: &str| { + !value.is_empty() + && !matches!(value, "." | "..") + && !value.contains(['/', '\\', ':']) + && !value.ends_with(['.', ' ']) + }; + if durable.plugin_id != plugin_id + || !is_version_component(&durable.version) + || !is_version_component(&backup_version) + { + return refuse( + "the recovery record does not name a valid version of this plugin; all bytes were kept" + .to_string(), + ); + } + let backup_version_directory = install_root.join(plugin_id).join(&backup_version); + let Some(approved_digest) = durable.active_content_digest() else { + return refuse( + "a recovery backup exists and the installation record carries no approved digest; \ + the backup and any interrupted bytes were kept" + .to_string(), + ); + }; + let backup_digest = super::signature::content_digest(backup_directory) + .map_err(|error| format!("the recovery backup is unreadable: {}", error.message()))?; + if durable.version == backup_version { + let version_digest = if backup_version_directory.exists() { + Some( + super::signature::content_digest(&backup_version_directory).map_err(|error| { + format!("the interrupted version is unreadable: {}", error.message()) + })?, + ) + } else { + None + }; + if version_digest.as_deref() == Some(approved_digest) { + // The version directory already holds the approved bytes; the + // backup is a stale leftover and safe to remove. + std::fs::remove_dir_all(backup_directory).map_err(|error| { + format!("a stale recovery backup could not be removed: {error}") + })?; + return Ok(()); + } + if backup_digest == approved_digest { + // Restore the approved bytes over the interrupted ones. + if backup_version_directory.exists() { + if let Err(error) = std::fs::remove_dir_all(&backup_version_directory) { + return refuse(format!( + "a recovery backup exists but the interrupted version could not be removed: {error}" + )); + } + } + if let Err(error) = std::fs::rename(backup_directory, &backup_version_directory) { + return refuse(format!( + "the recovery backup could not be restored: {error}" + )); + } + return Ok(()); + } + return refuse(format!( + "an interrupted install left an ambiguous state: neither the version directory nor \ + its recovery backup matches the approved digest recorded in the installation \ + record; the backup at {} and any interrupted bytes were kept", + backup_directory.display() + )); + } + // The durable record approves a different version: this backup is an + // orphan. It may only be cleared once the approved bytes are provably + // intact in their own directory, and it is restored there instead when + // it provably holds them. + let approved_directory = install_root.join(&durable.plugin_id).join(&durable.version); + let approved_digest_matches = approved_directory.exists() + && super::signature::content_digest(&approved_directory) + .map(|digest| digest == approved_digest) + .unwrap_or(false); + if !approved_digest_matches && backup_digest == approved_digest { + // The approved bytes only exist in this backup: restore them to + // their own directory before publishing another version. + std::fs::rename(backup_directory, &approved_directory).map_err(|error| { + format!( + "the recovery backup could not be restored to {}: {error}", + approved_directory.display() + ) + })?; + } else if !approved_digest_matches { + return refuse(format!( + "an interrupted install left an ambiguous state: the approved version {} is missing \ + or does not match its record digest, so the recovery backup cannot be judged; the \ + backup at {} was kept", + durable.version, + backup_directory.display() + )); + } + if backup_directory.exists() { + std::fs::remove_dir_all(backup_directory) + .map_err(|error| format!("a stale recovery backup could not be removed: {error}"))?; + } + Ok(()) +} + +fn activate_staged_version_and_record( staging_directory: &Path, version_directory: &Path, + install_root: &Path, + version: &str, + record: &InstallationRecord, + cleanup_backup: &dyn Fn(&Path) -> std::io::Result<()>, ) -> Result<(), String> { - let staged_manifest = read_snapshot_manifest(staging_directory)?; - validate_manifest(&staged_manifest).map_err(|rejection| rejection.message)?; + let record_path = install_root + .join(&record.plugin_id) + .join("installation.json"); + let temp_path = record_path.with_extension("json.tmp"); + std::fs::write( + &temp_path, + serde_json::to_string_pretty(record).map_err(|error| error.to_string())?, + ) + .map_err(|error| format!("could not stage the installation record: {error}"))?; + // The backup carries the full version name: adjacent patch versions + // (1.0.0 and 1.0.1) must never share one recovery path. Leftovers were + // already resolved before staging; a backup appearing here would mean a + // concurrent writer and is refused instead of deleted or restored. + let plugin_directory = install_root.join(&record.plugin_id); + let backup_directory = plugin_directory.join(format!("{version}.previous-tmp")); + if backup_directory.exists() { + let _ = std::fs::remove_file(&temp_path); + return Err(format!( + "a recovery backup for {version} appeared during the transaction; the state was kept" + )); + } if !version_directory.exists() { - return std::fs::rename(staging_directory, version_directory) - .map_err(|error| format!("activation failed: {error}")); + // First install: activate, then commit. No previous version exists, + // so a failed commit removes the new bytes again. + if let Err(activation_error) = std::fs::rename(staging_directory, version_directory) { + let _ = std::fs::remove_file(&temp_path); + return Err(format!("activation failed: {activation_error}")); + } + if let Err(record_error) = std::fs::rename(&temp_path, &record_path) { + let removal_error = std::fs::remove_dir_all(version_directory).err(); + let _ = std::fs::remove_file(&temp_path); + return Err(match removal_error { + None => format!( + "could not write the installation record ({record_error}); the new version was removed" + ), + Some(removal_error) => format!( + "could not write the installation record ({record_error}) and the new version could not be removed ({removal_error}); the record still names no installed version" + ), + }); + } + return Ok(()); } - // Replacing an existing version: move the current directory aside first, - // and restore it if activation fails, so a failed same-version reinstall - // never leaves the install record pointing at missing bytes. - let backup_directory = version_directory.with_extension("previous-tmp"); - if backup_directory.exists() { - std::fs::remove_dir_all(&backup_directory).map_err(|error| error.to_string())?; + // Replacing an existing version: move the current directory aside first + // and keep it until the record has committed. + if let Err(backup_error) = std::fs::rename(version_directory, &backup_directory) { + let _ = std::fs::remove_file(&temp_path); + return Err(format!( + "could not back up the current version: {backup_error}" + )); } - std::fs::rename(version_directory, &backup_directory) - .map_err(|error| format!("could not back up the current version: {error}"))?; if let Err(activation_error) = std::fs::rename(staging_directory, version_directory) { - if let Err(restore_error) = std::fs::rename(&backup_directory, version_directory) { - return Err(format!( + let restore_error = std::fs::rename(&backup_directory, version_directory).err(); + let _ = std::fs::remove_file(&temp_path); + return Err(match restore_error { + Some(restore_error) => format!( "activation failed ({activation_error}) and the previous version could not be restored ({restore_error})" - )); - } - return Err(format!("activation failed: {activation_error}")); + ), + None => format!("activation failed: {activation_error}"), + }); + } + if let Err(record_error) = std::fs::rename(&temp_path, &record_path) { + // The old record still names the old version, so the old bytes must + // come back; a failed restore is reported, never swallowed. + let restore_result = (|| { + std::fs::remove_dir_all(version_directory)?; + std::fs::rename(&backup_directory, version_directory)?; + Ok::<(), std::io::Error>(()) + })(); + let _ = std::fs::remove_file(&temp_path); + return Err(match restore_result { + Ok(()) => format!( + "could not write the installation record ({record_error}); the previous version was restored" + ), + Err(restore_error) => format!( + "could not write the installation record ({record_error}) and the previous version could not be restored ({restore_error}); the recovery backup at {} was kept for the next attempt", + backup_directory.display() + ), + }); } - let _ = std::fs::remove_dir_all(&backup_directory); + // Transaction committed: the backup is best-effort cleanup from here. + let _ = cleanup_backup(&backup_directory); Ok(()) } +/// Verifies a rollback candidate the way it was originally installed: the +/// restored package must match its record, validate, and satisfy the trust +/// policy with its own provenance. The candidate lives in the install root, +/// so a bundled claim can never be honored here. +pub(crate) fn verify_rollback_candidate( + previous_directory: &Path, + plugin_id: &str, + previous_version: &str, + origin: super::signature::InstallOrigin, + expected_content_digest: Option<&str>, +) -> Result<(), String> { + let manifest = read_package_manifest(previous_directory)?; + if manifest["id"].as_str() != Some(plugin_id) + || manifest["version"].as_str() != Some(previous_version) + { + return Err("the previous package does not match its installation record".to_string()); + } + validate_manifest(&manifest).map_err(|rejection| rejection.message)?; + super::signature::verify_for_origin( + previous_directory, + plugin_id, + previous_version, + &manifest, + origin, + expected_content_digest, + None, + &super::signature::trusted_keys(), + ) + .map_err(|error| error.message()) +} + +/// Reads the current install record of a plugin; `None` when the plugin has +/// no record yet or the record is unreadable, which a fresh install then +/// replaces with a complete record. +fn read_installation_record(install_root: &Path, plugin_id: &str) -> Option { + let text = + std::fs::read_to_string(install_root.join(plugin_id).join("installation.json")).ok()?; + serde_json::from_str(&text).ok() +} + +/// Writes an installation record through temp + rename so a crash never +/// leaves a truncated record. pub(crate) fn write_installation_record( install_root: &Path, - plugin_id: &str, - version: &str, + record: &InstallationRecord, ) -> Result<(), String> { - let record_path = install_root.join(plugin_id).join("installation.json"); - let record = InstallationRecord { - schema_version: 1, - plugin_id: plugin_id.to_string(), - version: version.to_string(), - enabled: true, - }; + let record_path = install_root + .join(&record.plugin_id) + .join("installation.json"); std::fs::create_dir_all(record_path.parent().ok_or("record path")?) .map_err(|error| error.to_string())?; + let temp_path = record_path.with_extension("json.tmp"); std::fs::write( - &record_path, - serde_json::to_string_pretty(&record).map_err(|error| error.to_string())?, + &temp_path, + serde_json::to_string_pretty(record).map_err(|error| error.to_string())?, ) .map_err(|error| error.to_string())?; + std::fs::rename(&temp_path, &record_path).map_err(|error| error.to_string())?; Ok(()) } -fn read_snapshot_manifest(snapshot: &Path) -> Result { - let text = std::fs::read_to_string(snapshot.join("plugin.json")) +/// Serializes every approved program of the review into its canonical JSON +/// text, the same evidence the macOS record keeps for re-approval checks. +fn canonical_programs(programs: &[serde_json::Value]) -> Vec { + programs + .iter() + .map(|program| serde_json::to_string(program).unwrap_or_default()) + .collect() +} + +/// SHA-256 of the source archive, recorded with the disk provenance. +fn archive_digest(source: &Path) -> Result { + let mut file = std::fs::File::open(source) + .map_err(|error| format!("{} could not be opened: {error}", source.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .map_err(|error| format!("{} could not be read: {error}", source.display()))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn read_package_manifest(package_root: &Path) -> Result { + let text = std::fs::read_to_string(package_root.join("plugin.json")) .map_err(|error| format!("the package has no readable plugin.json: {error}"))?; serde_json::from_str(&text).map_err(|error| format!("plugin.json is not valid JSON: {error}")) } -fn content_digest(manifest: &serde_json::Value) -> String { +fn manifest_replay_digest(manifest: &serde_json::Value) -> String { let mut hasher = Sha256::new(); hasher.update(manifest.to_string().as_bytes()); format!("{:x}", hasher.finalize()) @@ -530,6 +1482,15 @@ fn extract_zip_snapshot_inner(source: &Path, snapshot: &Path) -> Result<(), Stri if let Some(parent) = target.parent() { std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; } + // Windows paths are case-insensitive: two entries that differ only in + // case (or a duplicated name) would silently overwrite each other and + // break the approved file set, so the second one is a rejection. + if target.exists() { + return Err(format!( + "archive entry {} conflicts with an earlier entry", + name.display() + )); + } let mut output = std::fs::File::create(&target).map_err(|error| error.to_string())?; let declared_size = entry.size(); let copied = std::io::copy(&mut entry.by_ref().take(declared_size + 1), &mut output) @@ -544,7 +1505,7 @@ fn extract_zip_snapshot_inner(source: &Path, snapshot: &Path) -> Result<(), Stri // A ZIP that unpacked into a single wrapper directory still yields a // package root containing plugin.json directly. unwrap_single_directory(snapshot); - read_snapshot_manifest(snapshot)?; + read_package_manifest(snapshot)?; Ok(()) } @@ -590,7 +1551,7 @@ fn string_list(value: &serde_json::Value) -> Vec { .unwrap_or_default() } -fn rescan_after_change(app: &AppHandle) -> Result<(), String> { +pub(crate) fn rescan_after_change(app: &AppHandle) -> Result<(), String> { app.state::() .refresh(|| super::scan::build_catalog(app)) .map(|_| ()) @@ -627,12 +1588,18 @@ fn discard_snapshot(snapshot: &Path) { /// Time- and process-derived suffix; uniqueness across sessions is what the /// snapshot directory needs, not cryptographic randomness. -fn uuid_like_suffix() -> String { +pub(crate) fn uuid_like_suffix() -> String { + static SESSION_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_nanos()) .unwrap_or_default(); - format!("{}-{:x}", std::process::id(), nanos) + format!( + "{}-{:x}-{:x}", + std::process::id(), + nanos, + SESSION_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ) } /// Removes snapshots left behind by a crashed review session at startup. @@ -649,8 +1616,14 @@ pub fn cleanup_stale_snapshots(app: &AppHandle) { }; for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with(SNAPSHOT_PREFIX) { - let _ = std::fs::remove_dir_all(entry.path()); + if name.starts_with(SNAPSHOT_PREFIX) || name.starts_with("marketplace-download-") { + // Snapshot directories and their `.source.json` sidecar records + // share the session-id prefix. + if entry.path().is_dir() { + let _ = std::fs::remove_dir_all(entry.path()); + } else { + let _ = std::fs::remove_file(entry.path()); + } } } } diff --git a/windows/tauri/src-tauri/src/plugin_host/install_tests.rs b/windows/tauri/src-tauri/src/plugin_host/install_tests.rs index 623b9069f9..b082abb94e 100644 --- a/windows/tauri/src-tauri/src/plugin_host/install_tests.rs +++ b/windows/tauri/src-tauri/src/plugin_host/install_tests.rs @@ -1,16 +1,41 @@ -/// M2 disk-import tests exercise the internal functions directly (the Tauri -/// commands are thin AppHandle wrappers): snapshot isolation, ZIP safety, -/// staging install, and rollback version selection. +//! M2 disk-import tests exercise the internal functions directly (the Tauri +//! commands are thin AppHandle wrappers): snapshot isolation, ZIP safety, +//! the staged publish transaction, and rollback verification. The M1 +//! trust-chain tests cover the same transaction against the recorded +//! content digest. + mod install_tests { use crate::plugin_host::install::{ - copy_directory_snapshot, extract_zip_snapshot, staging_install, write_installation_record, + copy_directory_snapshot, extract_zip_snapshot, publish_installed_version, }; - use crate::plugin_host::store; - use crate::plugin_host::store::InstallationRecord; + use crate::plugin_host::signature::{self, InstallOrigin}; + use crate::plugin_host::store::{self, DiskProvenance, InstallationRecord}; use crate::plugin_host::tests::read_fixture_for_tests; + use serde_json::json; use std::path::PathBuf; - fn unique_temp_root(label: &str) -> PathBuf { + struct TestRoot(PathBuf); + + impl std::ops::Deref for TestRoot { + type Target = PathBuf; + fn deref(&self) -> &PathBuf { + &self.0 + } + } + + impl AsRef for TestRoot { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } + } + + impl Drop for TestRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn unique_temp_root(label: &str) -> TestRoot { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("system clock") @@ -20,13 +45,20 @@ mod install_tests { std::process::id() )); std::fs::create_dir_all(&root).expect("temp root"); - root + TestRoot(root) } + const TEST_PLUGIN_ID: &str = "io.example.acme-php"; + fn package_directory(root: &std::path::Path, version: &str) -> PathBuf { let directory = root.join(format!("pkg-{version}")); std::fs::create_dir_all(&directory).expect("package dir"); let mut manifest = read_fixture_for_tests("language-packaged-server.plugin.json"); + // A community install requires a non-official ID and an unsigned + // trust declaration; the official-namespace fixture is reshaped the + // way a real disk package looks. + manifest["id"] = serde_json::json!(TEST_PLUGIN_ID); + manifest["vendor"]["signatureRequirement"] = serde_json::json!("none"); manifest["version"] = serde_json::json!(version); std::fs::write( directory.join("plugin.json"), @@ -39,6 +71,33 @@ mod install_tests { directory } + /// A schema 2 community record for `package_directory`, with the content + /// digest the real install flow records from the approved snapshot. + fn community_record( + plugin_id: &str, + version: &str, + package_directory: &std::path::Path, + ) -> InstallationRecord { + InstallationRecord { + schema_version: 2, + plugin_id: plugin_id.to_string(), + version: version.to_string(), + previous_version: None, + enabled: true, + origin: Some(InstallOrigin::Community), + previous_origin: None, + marketplace: None, + previous_marketplace: None, + disk: Some(DiskProvenance { + file_name: "plugin.zip".to_string(), + archive_sha256: None, + content_digest: signature::content_digest(package_directory).expect("digest"), + approved_programs: Vec::new(), + }), + previous_disk: None, + } + } + #[test] fn directory_snapshot_copies_contents() { let root = unique_temp_root("snapshot"); @@ -114,105 +173,1705 @@ mod install_tests { let _ = std::fs::remove_dir_all(&root); } + /// Two entries that only differ in case (or a duplicated name) would + /// silently overwrite each other on Windows and break the approved file + /// set, so the second one is a rejection. + #[test] + fn zip_case_conflicting_entries_are_rejected() { + let root = unique_temp_root("zip-conflict"); + let zip_path = root.join("conflict.zip"); + let zip_file = std::fs::File::create(&zip_path).expect("zip file"); + let mut writer = zip::ZipWriter::new(zip_file); + let options: zip::write::SimpleFileOptions = Default::default(); + let mut manifest = read_fixture_for_tests("language-packaged-server.plugin.json"); + manifest["version"] = serde_json::json!("1.0.0"); + let manifest_text = serde_json::to_string_pretty(&manifest).expect("manifest text"); + for name in ["plugin.json", "Plugin.json"] { + writer.start_file(name, options).expect("entry"); + std::io::Write::write_all(&mut writer, manifest_text.as_bytes()).expect("entry bytes"); + } + writer.finish().expect("zip finish"); + let snapshot = root.join("snapshot"); + assert!(extract_zip_snapshot(&zip_path, &snapshot).is_err()); + let _ = std::fs::remove_dir_all(&root); + } + #[test] - fn staging_install_replaces_version_and_rejects_broken_manifests() { + fn publish_rejects_broken_manifests_and_keeps_the_active_version() { let root = unique_temp_root("staging"); let install_root = root.join("plugins"); let good = package_directory(&root, "1.0.0"); - staging_install(&install_root, "acme.php", "1.0.0", &good).expect("installs"); - assert!(install_root.join("acme.php/1.0.0/plugin.json").is_file()); - write_installation_record(&install_root, "acme.php", "1.0.0").expect("record"); + let record = community_record(TEST_PLUGIN_ID, "1.0.0", &good); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &good, + record.clone(), + ) + .expect("installs"); + assert!(install_root + .join(TEST_PLUGIN_ID) + .join("1.0.0/plugin.json") + .is_file()); // A broken package fails validation and leaves the active version. let broken = root.join("pkg-broken"); std::fs::create_dir_all(&broken).expect("broken dir"); std::fs::write(broken.join("plugin.json"), "{ not json").expect("broken manifest"); - assert!(staging_install(&install_root, "acme.php", "2.0.0", &broken).is_err()); - assert!(!install_root.join("acme.php/2.0.0").exists()); - assert!(install_root.join("acme.php/1.0.0/plugin.json").is_file()); + let broken_record = community_record(TEST_PLUGIN_ID, "2.0.0", &broken); + assert!(publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "2.0.0", + &broken, + broken_record, + ) + .is_err()); + assert!(!install_root.join(TEST_PLUGIN_ID).join("2.0.0").exists()); + assert!(install_root + .join(TEST_PLUGIN_ID) + .join("1.0.0/plugin.json") + .is_file()); // The staging directory is cleaned up on failure. - assert!(!install_root.join("acme.php/.staging-2.0.0").exists()); + assert!(!install_root + .join(TEST_PLUGIN_ID) + .join(".staging-2.0.0") + .exists()); // A successful upgrade keeps both versions for rollback. let upgrade = package_directory(&root, "2.0.0"); - staging_install(&install_root, "acme.php", "2.0.0", &upgrade).expect("upgrade"); - assert!(install_root.join("acme.php/1.0.0/plugin.json").is_file()); - assert!(install_root.join("acme.php/2.0.0/plugin.json").is_file()); + let upgrade_record = community_record(TEST_PLUGIN_ID, "2.0.0", &upgrade); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "2.0.0", + &upgrade, + upgrade_record, + ) + .expect("upgrade"); + assert!(install_root + .join(TEST_PLUGIN_ID) + .join("1.0.0/plugin.json") + .is_file()); + assert!(install_root + .join(TEST_PLUGIN_ID) + .join("2.0.0/plugin.json") + .is_file()); let _ = std::fs::remove_dir_all(&root); } + /// The staged copy must match the digest recorded for the approved + /// snapshot: a snapshot swapped between review and publish never becomes + /// the active version, and the old version stays intact. #[test] - fn same_version_activation_failure_restores_the_previous_directory() { - let root = unique_temp_root("staging-restore"); + fn publish_binds_the_staged_bytes_to_the_approved_digest() { + let root = unique_temp_root("staging-digest"); let install_root = root.join("plugins"); - let good = package_directory(&root, "1.0.0"); - staging_install(&install_root, "acme.php", "1.0.0", &good).expect("installs"); - let installed_marker = install_root.join("acme.php/1.0.0/marker.txt"); - std::fs::write(&installed_marker, b"original").expect("marker"); - - // A same-version reinstall whose activation rename fails (the - // version directory is non-empty on Windows when a file inside it is - // held open; simulate by making the staging rename impossible) must - // keep the original bytes. - let replacement = package_directory(&root, "1.0.0-alt"); - // Force failure: pre-create the staging path as a file so rename onto - // it cannot succeed after the backup move. - std::fs::create_dir_all(install_root.join("acme.php")).expect("plugin dir"); - std::fs::write(install_root.join("acme.php/.staging-1.0.0"), b"occupied").expect("blocker"); - // staging_install removes the blocking staging path first, so instead - // occupy the TARGET by opening it with a share lock is not testable - // portably; simulate rename failure by removing write permission is - // also platform-specific. The contract under test is the restore - // path: inject failure by making the staging directory disappear - // between validate and activate is not possible via this API, so we - // assert the restore path directly through the backup/restore - // sequence: rename current aside, fail the staging rename, restore. - let version_directory = install_root.join("acme.php").join("1.0.0"); - let backup_directory = version_directory.with_extension("previous-tmp"); - std::fs::rename(&version_directory, &backup_directory).expect("backup move"); - // Failing activation: staging path does not exist. - assert!(std::fs::rename(root.join("pkg-nonexistent"), &version_directory).is_err()); - std::fs::rename(&backup_directory, &version_directory).expect("restore move"); - assert!(installed_marker.exists(), "original bytes restored"); + let approved = package_directory(&root, "1.0.0"); + let record = community_record(TEST_PLUGIN_ID, "2.0.0", &approved); + + let tampered = package_directory(&root, "2.0.0"); + std::fs::write( + tampered.join("language-server/bin/intelephense.js"), + b"evil", + ) + .expect("tampered entry"); + assert!(publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "2.0.0", + &tampered, + record.clone(), + ) + .is_err()); + assert!(!install_root.join(TEST_PLUGIN_ID).join("2.0.0").exists()); + + // Publishing the approved bytes succeeds and the record carries the + // same digest the installed files recompute to. + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "2.0.0", + &approved, + record.clone(), + ) + .expect("installs"); + let stored: InstallationRecord = serde_json::from_str( + &std::fs::read_to_string(install_root.join(TEST_PLUGIN_ID).join("installation.json")) + .expect("record"), + ) + .expect("record parses"); + let installed_directory = install_root.join(TEST_PLUGIN_ID).join("2.0.0"); + assert_eq!( + stored + .disk + .as_ref() + .expect("disk provenance") + .content_digest, + signature::content_digest(&installed_directory).expect("digest") + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// A same-version reinstall of changed bytes is rejected: the record and + /// the version directory keep the originally approved state. + #[test] + fn same_version_reinstall_of_changed_bytes_is_rejected() { + let root = unique_temp_root("staging-same-version"); + let install_root = root.join("plugins"); + let approved = package_directory(&root, "1.0.0"); + let record = community_record(TEST_PLUGIN_ID, "1.0.0", &approved); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &approved, + record.clone(), + ) + .expect("installs"); + let marker = install_root + .join(TEST_PLUGIN_ID) + .join("1.0.0/language-server/bin/intelephense.js"); + assert_eq!(std::fs::read(&marker).expect("marker"), b"entry"); + + let replacement = package_directory(&root, "1.0.0-replacement"); + std::fs::write( + replacement.join("language-server/bin/intelephense.js"), + b"replaced", + ) + .expect("replacement entry"); + assert!(publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + record, + ) + .is_err()); assert_eq!( - std::fs::read(&installed_marker).expect("marker bytes"), - b"original" + std::fs::read(&marker).expect("marker"), + b"entry", + "the active version keeps its approved bytes" ); + assert!(!install_root + .join(TEST_PLUGIN_ID) + .join(".staging-1.0.0") + .exists()); let _ = std::fs::remove_dir_all(&root); } + /// Rollback verification: the restored version is checked against its + /// own recorded digest before the record may swap. The production + /// command performs exactly this check on the previous version + /// directory before writing the swapped record. #[test] - fn rollback_picks_the_highest_previous_version() { - // Mirrors the selection logic of declarative_plugins_rollback without - // an AppHandle: enumerate versions, pick highest != current. + fn rollback_rejects_a_tampered_previous_version() { let root = unique_temp_root("rollback"); let install_root = root.join("plugins"); - for version in ["1.0.0", "1.5.0", "2.0.0"] { - let package = package_directory(&root, version); - staging_install(&install_root, "acme.php", version, &package).expect("installs"); - } - write_installation_record(&install_root, "acme.php", "2.0.0").expect("record"); - let plugin_directory = install_root.join("acme.php"); - let mut versions: Vec = std::fs::read_dir(&plugin_directory) - .expect("plugin dir") + let first = package_directory(&root, "1.0.0"); + let first_digest = signature::content_digest(&first).expect("digest"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &first, + community_record(TEST_PLUGIN_ID, "1.0.0", &first), + ) + .expect("installs"); + + // Tampering with the previous version makes the rollback candidate + // unverifiable against its recorded digest. + let previous_directory = install_root.join(TEST_PLUGIN_ID).join("1.0.0"); + std::fs::write( + previous_directory.join("language-server/bin/intelephense.js"), + b"evil", + ) + .expect("tamper"); + let manifest_text = + std::fs::read_to_string(previous_directory.join("plugin.json")).expect("manifest"); + let manifest: serde_json::Value = serde_json::from_str(&manifest_text).expect("manifest"); + let error = signature::verify_for_origin( + &previous_directory, + TEST_PLUGIN_ID, + "1.0.0", + &manifest, + InstallOrigin::Community, + Some(first_digest.as_str()), + None, + &signature::trusted_keys(), + ) + .expect_err("tampered previous version must be refused"); + assert_eq!(error.code(), "plugin.trust.contentChanged"); + let _ = std::fs::remove_dir_all(&root); + } + + /// P1 regression: the installation record is part of the publish + /// transaction. When the record commit fails after the version bytes + /// were swapped (the record file is held open, so the rename onto it + /// fails on Windows), the previous version's bytes and its record must + /// both be restored, with no staging or temp leftovers. + /// A package directory with the declared version kept at `version` even + /// when the source directory name carries a distinguishing suffix, so a + /// same-version reinstall passes manifest validation. + fn same_version_package(root: &std::path::Path, version: &str, suffix: &str) -> PathBuf { + let directory = package_directory(root, &format!("{version}-{suffix}")); + let manifest_path = directory.join("plugin.json"); + let mut manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(&manifest_path).expect("manifest readable"), + ) + .expect("manifest is JSON"); + manifest["version"] = serde_json::json!(version); + std::fs::write( + &manifest_path, + serde_json::to_string_pretty(&manifest).expect("manifest text"), + ) + .expect("manifest rewritten"); + directory + } + + #[cfg(windows)] + #[test] + fn record_commit_failure_restores_the_previous_version() { + let root = unique_temp_root("staging-record-restore"); + let install_root = root.join("plugins"); + let approved = package_directory(&root, "1.0.0"); + let original_record = community_record(TEST_PLUGIN_ID, "1.0.0", &approved); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &approved, + original_record.clone(), + ) + .expect("first install succeeds"); + let original_digest = original_record + .disk + .as_ref() + .expect("disk provenance") + .content_digest + .clone(); + let marker = install_root + .join(TEST_PLUGIN_ID) + .join("1.0.0/language-server/bin/intelephense.js"); + assert_eq!(std::fs::read(&marker).expect("marker"), b"entry"); + + // A legitimate same-version reinstall: fresh snapshot, fresh review, + // matching digest binding - only the record commit will fail. + let replacement = same_version_package(&root, "1.0.0", "replacement"); + std::fs::write( + replacement.join("language-server/bin/intelephense.js"), + b"replaced", + ) + .expect("replacement entry"); + let replacement_record = community_record(TEST_PLUGIN_ID, "1.0.0", &replacement); + assert_ne!( + replacement_record + .disk + .as_ref() + .expect("disk") + .content_digest, + original_digest, + "the replacement must be a different approved byte set" + ); + + // Holding the record file open without FILE_SHARE_DELETE keeps + // Windows from renaming the new record over it, which fails the + // commit after the version bytes were already swapped. + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; + let record_path = install_root.join(TEST_PLUGIN_ID).join("installation.json"); + let record_lock = std::fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .open(&record_path) + .expect("record file opened"); + let publish_error = publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + replacement_record, + ) + .expect_err("the record commit must fail while the record is locked"); + drop(record_lock); + assert!( + publish_error.contains("record"), + "the failure must name the record commit: {publish_error}" + ); + + // The old version bytes and the old record are both restored. + assert_eq!( + std::fs::read(&marker).expect("marker"), + b"entry", + "the previous version bytes must be restored" + ); + let stored: InstallationRecord = serde_json::from_str( + &std::fs::read_to_string(install_root.join(TEST_PLUGIN_ID).join("installation.json")) + .expect("record"), + ) + .expect("record parses"); + assert_eq!( + stored.disk.as_ref().expect("disk").content_digest, + original_digest, + "the old record must survive the failed transaction" + ); + let plugin_directory = install_root.join(TEST_PLUGIN_ID); + let leftovers: Vec = std::fs::read_dir(&plugin_directory) + .expect("plugin directory readable") .flatten() - .filter_map(|entry| { - let name = entry.file_name().to_string_lossy().to_string(); - let path = entry.path(); - (path.is_dir() && path.join("plugin.json").is_file()).then_some(name) - }) + .map(|entry| entry.file_name().to_string_lossy().to_string()) .collect(); - versions.sort_by(|left, right| store::compare_versions(left, right)); - versions.reverse(); - let record: InstallationRecord = serde_json::from_str( + assert!( + leftovers + .iter() + .all(|name| name == "1.0.0" || name == "installation.json"), + "the failed transaction must leave no staging, backup, or temp entries: {leftovers:?}" + ); + + // Failure retry: with the lock released, the same reinstall commits. + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + community_record(TEST_PLUGIN_ID, "1.0.0", &replacement), + ) + .expect("the retry succeeds once the record is writable"); + assert_eq!( + std::fs::read(&marker).expect("marker"), + b"replaced", + "the retry installs the new bytes" + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// A record that cannot even be staged (a directory blocks the temp + /// path) fails before any version byte moves. + #[test] + fn record_staging_failure_leaves_the_active_version_untouched() { + let root = unique_temp_root("staging-record-preflight"); + let install_root = root.join("plugins"); + let approved = package_directory(&root, "1.0.0"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &approved, + community_record(TEST_PLUGIN_ID, "1.0.0", &approved), + ) + .expect("first install succeeds"); + let marker = install_root + .join(TEST_PLUGIN_ID) + .join("1.0.0/language-server/bin/intelephense.js"); + + let replacement = same_version_package(&root, "1.0.0", "replacement"); + std::fs::write( + replacement.join("language-server/bin/intelephense.js"), + b"replaced", + ) + .expect("replacement entry"); + // The temp record path is occupied by a directory. + std::fs::create_dir_all( + install_root + .join(TEST_PLUGIN_ID) + .join("installation.json.tmp"), + ) + .expect("blocker planted"); + let publish_error = publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + community_record(TEST_PLUGIN_ID, "1.0.0", &replacement), + ) + .expect_err("publish must fail when the record cannot be staged"); + eprintln!("publish error: {publish_error}"); + assert_eq!( + std::fs::read(&marker).expect("marker"), + b"entry", + "the active version must be untouched when the record cannot be staged" + ); + assert!( + publish_error.contains("record"), + "the failure must name the record: {publish_error}" + ); + let stored: InstallationRecord = serde_json::from_str( + &std::fs::read_to_string(install_root.join(TEST_PLUGIN_ID).join("installation.json")) + .expect("record"), + ) + .expect("record parses"); + assert_eq!(stored.version, "1.0.0"); + let _ = std::fs::remove_dir_all(&root); + } + + /// A first install whose record commit fails publishes no bytes at all: + /// there is no previous version to fall back to, so the version + /// directory is removed again. + #[test] + fn record_commit_failure_on_first_install_publishes_nothing() { + let root = unique_temp_root("staging-record-first"); + let install_root = root.join("plugins"); + let package = package_directory(&root, "1.0.0"); + // A directory occupies the record path, so the commit cannot land. + std::fs::create_dir_all(install_root.join(TEST_PLUGIN_ID).join("installation.json")) + .expect("record path blocker planted"); + assert!(publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &package, + community_record(TEST_PLUGIN_ID, "1.0.0", &package), + ) + .is_err()); + assert!( + !install_root.join(TEST_PLUGIN_ID).join("1.0.0").exists(), + "a failed first install must not leave version bytes behind" + ); + let _ = std::fs::remove_dir_all(&root); + } + + // ---------- signed disk packages (M1: the disk channel must accept + // publisher-signed packages) and prepare/confirm byte binding ---------- + + /// Writes a signature document over the current package bytes with the + /// shared RFC 8032 test key, exactly like the release signing flow. + fn write_test_signature( + package_root: &std::path::Path, + key_id: &str, + plugin_id: &str, + plugin_version: &str, + signing: &ed25519_dalek::SigningKey, + ) { + use base64::Engine; + use ed25519_dalek::Signer; + let files = signature::file_digests(package_root).expect("package digests"); + let payload = signature::canonical_payload(key_id, plugin_id, plugin_version, &files); + let signature_bytes = signing.sign(&payload); + let document = signature::SignatureDocument { + schema_version: signature::SCHEMA_VERSION, + algorithm: signature::ALGORITHM.to_string(), + key_id: key_id.to_string(), + plugin_id: plugin_id.to_string(), + plugin_version: plugin_version.to_string(), + files, + signature: base64::engine::general_purpose::STANDARD.encode(signature_bytes.to_bytes()), + }; + std::fs::write( + package_root.join(signature::SIGNATURE_FILE_NAME), + serde_json::to_string_pretty(&document).expect("document text"), + ) + .expect("signature document written"); + } + + /// A disk-installable package whose manifest declares the given trust + /// policy; the official-namespace fixture is reshaped the way a signed + /// community package looks. + fn declared_package_directory( + root: &std::path::Path, + plugin_id: &str, + declared: &str, + ) -> PathBuf { + let directory = root.join(format!("pkg-{plugin_id}")); + std::fs::create_dir_all(&directory).expect("package dir"); + let mut manifest = read_fixture_for_tests("language-packaged-server.plugin.json"); + manifest["id"] = serde_json::json!(plugin_id); + manifest["vendor"]["signatureRequirement"] = serde_json::json!(declared); + manifest["version"] = serde_json::json!("1.0.0"); + std::fs::write( + directory.join("plugin.json"), + serde_json::to_string_pretty(&manifest).expect("manifest text"), + ) + .expect("manifest file"); + let server_directory = directory.join("language-server").join("bin"); + std::fs::create_dir_all(&server_directory).expect("server dir"); + std::fs::write(server_directory.join("intelephense.js"), b"entry").expect("entry file"); + directory + } + + fn test_trusted_keys( + signing: &ed25519_dalek::SigningKey, + ) -> std::collections::BTreeMap { + std::collections::BTreeMap::from([("lithe-test-v1".to_string(), signing.verifying_key())]) + } + + /// P1 regression: a publisher-signed package must be installable from + /// disk. The disk channel records community provenance and the recorded + /// digest, and the verified publisher signature satisfies the pinned + /// policy, so the signed package installs and loads. + #[test] + fn signed_disk_package_installs_and_loads() { + let signing = signature::rfc8032_signing_key_for_tests(); + signature::with_trusted_keys_for_tests(test_trusted_keys(&signing), || { + let root = unique_temp_root("signed-disk"); + let source = + declared_package_directory(&root, "io.example.signed-php", "publisherPackage"); + write_test_signature( + &source, + "lithe-test-v1", + "io.example.signed-php", + "1.0.0", + &signing, + ); + + let temporary_root = root.join("plugin-temp"); + let install_root = root.join("plugins"); + let prepared = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-test-session", + source.to_str().expect("source path"), + ) + .expect("prepare accepts the signed package"); + let confirmed = crate::plugin_host::install::confirm_disk_install_snapshot( + &install_root, + &temporary_root, + &prepared.session_id, + &prepared.manifest_digest, + ) + .expect("confirm accepts the signed package"); + assert_eq!( + confirmed.record.origin, + Some(InstallOrigin::Community), + "the origin stays the real disk channel" + ); + crate::plugin_host::install::publish_installed_version( + &install_root, + &confirmed.plugin_id, + &confirmed.version, + &temporary_root.join(&prepared.session_id), + confirmed.record, + ) + .expect("publish succeeds"); + + let catalog = + crate::plugin_host::scan::build_catalog_from(&root.join("none"), &install_root) + .expect("catalog builds"); + let entry = &catalog + .iter() + .find(|package| package.entry.source == "installed") + .expect("installed entry") + .entry; + assert_eq!( + entry.status, "accepted", + "the signed disk package must load: {:?}", + entry.rejection + ); + let _ = std::fs::remove_dir_all(&root); + }); + } + + #[test] + fn signed_disk_package_with_an_unknown_key_is_refused() { + let signing = signature::rfc8032_signing_key_for_tests(); + signature::with_trusted_keys_for_tests(test_trusted_keys(&signing), || { + let root = unique_temp_root("signed-unknown-key"); + let source = + declared_package_directory(&root, "io.example.signed-php", "publisherPackage"); + // Signed by a key ID the host does not trust at all. + let stranger = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]); + write_test_signature( + &source, + "stranger-test-key", + "io.example.signed-php", + "1.0.0", + &stranger, + ); + + let temporary_root = root.join("plugin-temp"); + let error = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-test-session", + source.to_str().expect("source path"), + ) + .expect_err("an unknown signing key must be refused"); + assert!(error.contains("trust"), "{error}"); + let _ = std::fs::remove_dir_all(&root); + }); + } + + #[test] + fn signed_disk_package_with_a_bad_signature_is_refused() { + let signing = signature::rfc8032_signing_key_for_tests(); + signature::with_trusted_keys_for_tests(test_trusted_keys(&signing), || { + let root = unique_temp_root("signed-bad"); + let source = + declared_package_directory(&root, "io.example.signed-php", "publisherPackage"); + write_test_signature( + &source, + "lithe-test-v1", + "io.example.signed-php", + "1.0.0", + &signing, + ); + // Tamper after signing. + std::fs::write(source.join("language-server/bin/intelephense.js"), b"evil") + .expect("tampered entry"); + + let temporary_root = root.join("plugin-temp"); + let error = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-test-session", + source.to_str().expect("source path"), + ) + .expect_err("a bad signature must be refused"); + assert!(error.contains("signature"), "{error}"); + let _ = std::fs::remove_dir_all(&root); + }); + } + + #[test] + fn same_team_disk_package_is_refused_even_when_signed() { + let signing = signature::rfc8032_signing_key_for_tests(); + signature::with_trusted_keys_for_tests(test_trusted_keys(&signing), || { + let root = unique_temp_root("signed-same-team"); + let source = declared_package_directory(&root, "io.example.team-php", "sameTeamAsHost"); + write_test_signature( + &source, + "lithe-test-v1", + "io.example.team-php", + "1.0.0", + &signing, + ); + + let temporary_root = root.join("plugin-temp"); + let error = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-test-session", + source.to_str().expect("source path"), + ) + .expect_err("a sameTeamAsHost disk package must be refused"); + assert!(error.contains("inside Lithe"), "{error}"); + let _ = std::fs::remove_dir_all(&root); + }); + } + + /// P1 regression: the approval binds to the bytes the user reviewed. + /// Replacing a package file after prepare (the manifest unchanged) must + /// not install: the frozen session digest no longer matches. + #[test] + fn confirm_rejects_bytes_changed_after_prepare() { + let signing = signature::rfc8032_signing_key_for_tests(); + signature::with_trusted_keys_for_tests(test_trusted_keys(&signing), || { + let root = unique_temp_root("confirm-swap"); + let source = declared_package_directory(&root, "io.example.swap-php", "none"); + let temporary_root = root.join("plugin-temp"); + let prepared = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-test-session", + source.to_str().expect("source path"), + ) + .expect("prepare succeeds"); + + // Swap a non-manifest file inside the prepared snapshot. + let snapshot = temporary_root.join(&prepared.session_id); + std::fs::write( + snapshot.join("language-server/bin/intelephense.js"), + b"replaced after review", + ) + .expect("snapshot file swapped"); + + let install_root = root.join("plugins"); + let error = crate::plugin_host::install::confirm_disk_install_snapshot( + &install_root, + &temporary_root, + &prepared.session_id, + &prepared.manifest_digest, + ) + .expect_err("confirm must refuse bytes that changed after prepare"); + assert!(error.contains("changed after review"), "{error}"); + let _ = std::fs::remove_dir_all(&root); + }); + } + + /// Adding or removing an ordinary file after prepare is the same + /// rejection: the approved file set is exact. + #[test] + fn confirm_rejects_added_and_removed_files_after_prepare() { + let signing = signature::rfc8032_signing_key_for_tests(); + signature::with_trusted_keys_for_tests(test_trusted_keys(&signing), || { + let root = unique_temp_root("confirm-files"); + let source = declared_package_directory(&root, "io.example.files-php", "none"); + let temporary_root = root.join("plugin-temp"); + let install_root = root.join("plugins"); + + // An added file after prepare. + let prepared = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-added", + source.to_str().expect("source path"), + ) + .expect("prepare succeeds"); + std::fs::write( + temporary_root.join(&prepared.session_id).join("extra.js"), + b"added after review", + ) + .expect("file added to the snapshot"); + let error = crate::plugin_host::install::confirm_disk_install_snapshot( + &install_root, + &temporary_root, + &prepared.session_id, + &prepared.manifest_digest, + ) + .expect_err("an added file must be refused"); + assert!(error.contains("changed after review"), "{error}"); + + // A removed file after prepare. + let prepared = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-removed", + source.to_str().expect("source path"), + ) + .expect("prepare succeeds"); + std::fs::remove_file( + temporary_root + .join(&prepared.session_id) + .join("language-server/bin/intelephense.js"), + ) + .expect("file removed from the snapshot"); + let error = crate::plugin_host::install::confirm_disk_install_snapshot( + &install_root, + &temporary_root, + &prepared.session_id, + &prepared.manifest_digest, + ) + .expect_err("a removed file must be refused"); + assert!(error.contains("changed after review"), "{error}"); + let _ = std::fs::remove_dir_all(&root); + }); + } + + /// P1 regression: a rollback candidate can never be honored with + /// bundled provenance. The candidate lives in the install root, so the + /// seal check refuses even a signed, otherwise-valid package. + #[test] + fn rollback_cannot_restore_a_bundled_claiming_previous_version() { + let root = unique_temp_root("rollback-bundled"); + let install_root = root.join("plugins"); + let package = package_directory(&root, "1.0.0"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &package, + community_record(TEST_PLUGIN_ID, "1.0.0", &package), + ) + .expect("installs"); + + // A sameTeamAsHost candidate claimed as bundled is refused by the + // seal check: the candidate lives in the install root, never inside + // the host installation. + let sealed = declared_package_directory(&root, "io.example.sealed-php", "sameTeamAsHost"); + let error = crate::plugin_host::install::verify_rollback_candidate( + &sealed, + "io.example.sealed-php", + "1.0.0", + crate::plugin_host::signature::InstallOrigin::Bundled, + None, + ) + .expect_err("a bundled claim must not be restorable from the install root"); + assert!(error.contains("inside Lithe"), "{error}"); + + // An unsigned candidate claimed as bundled is refused as well: the + // bundled origin cannot legitimize an unsigned package. + let unsigned = declared_package_directory(&root, "io.example.unsigned-php", "none"); + let error = crate::plugin_host::install::verify_rollback_candidate( + &unsigned, + "io.example.unsigned-php", + "1.0.0", + crate::plugin_host::signature::InstallOrigin::Bundled, + None, + ) + .expect_err("a bundled claim must not legitimize an unsigned package"); + assert!(error.contains("community"), "{error}"); + + // A community candidate without its approved digest is refused too. + let error = crate::plugin_host::install::verify_rollback_candidate( + &install_root.join(TEST_PLUGIN_ID).join("1.0.0"), + TEST_PLUGIN_ID, + "1.0.0", + crate::plugin_host::signature::InstallOrigin::Community, + None, + ) + .expect_err("a community candidate needs its approved digest"); + assert!(error.contains("digest"), "{error}"); + + // The real previous provenance restores cleanly. + crate::plugin_host::install::verify_rollback_candidate( + &install_root.join(TEST_PLUGIN_ID).join("1.0.0"), + TEST_PLUGIN_ID, + "1.0.0", + crate::plugin_host::signature::InstallOrigin::Community, + Some( + crate::plugin_host::signature::content_digest(&package) + .expect("digest") + .as_str(), + ), + ) + .expect("the recorded provenance restores"); + let _ = std::fs::remove_dir_all(&root); + } + + // ---------- barrier ordering, cleanup, and recovery-backup handling ---- + + /// P1 regression: the install record is read and the rollback chain is + /// built inside the lifecycle barrier, so a mutation that commits while + /// another confirm is draining is not overwritten with a stale chain. + #[test] + fn record_chain_is_built_inside_the_barrier() { + let root = unique_temp_root("barrier-chain"); + let install_root = root.join("plugins"); + let temporary_root = root.join("plugin-temp"); + let first = package_directory(&root, "1.0.0"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &first, + community_record(TEST_PLUGIN_ID, "1.0.0", &first), + ) + .expect("first install"); + + // The session installs version 3.0.0. + let third = package_directory(&root, "3.0.0"); + let prepared = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-barrier", + third.to_str().expect("source path"), + ) + .expect("prepare succeeds"); + let snapshot = temporary_root.join(&prepared.session_id); + + // Another mutation commits version 2.0.0 while the confirm drains: + // the barrier closure lands that state before the record is built. + let second_digest = "2".repeat(64); + let first_digest = crate::plugin_host::signature::content_digest(&first).expect("digest"); + let stale_chain_overwritten = std::sync::Arc::new(std::sync::Mutex::new(false)); + let begin = { + let install_root = install_root.clone(); + let stale_chain_overwritten = stale_chain_overwritten.clone(); + move || -> Result<(), String> { + std::fs::write( + install_root.join(TEST_PLUGIN_ID).join("installation.json"), + json!({ + "schemaVersion": 2, + "pluginId": TEST_PLUGIN_ID, + "version": "2.0.0", + "enabled": true, + "origin": "community", + "disk": { + "fileName": "second.zip", + "archiveSHA256": null, + "contentDigest": second_digest, + "approvedPrograms": [] + }, + "previousVersion": "1.0.0", + "previousOrigin": "community", + "previousDisk": { + "fileName": "plugin.zip", + "archiveSHA256": null, + "contentDigest": first_digest, + "approvedPrograms": [] + } + }) + .to_string(), + ) + .expect("concurrent record written"); + *stale_chain_overwritten.lock().expect("flag") = true; + Ok(()) + } + }; + + let confirmed = crate::plugin_host::install::confirm_and_publish_in_barrier( + &install_root, + &temporary_root, + &prepared.session_id, + &prepared.manifest_digest, + &snapshot, + TEST_PLUGIN_ID, + &begin, + ) + .expect("the in-barrier confirm and publish succeed"); + assert!(*stale_chain_overwritten.lock().expect("flag")); + // The chain is derived from the post-barrier record: the 2.0.0 state + // that committed during the drain becomes the rollback target. + assert_eq!(confirmed.record.version, "3.0.0"); + assert_eq!( + confirmed.record.previous_version.as_deref(), + Some("2.0.0"), + "the rollback chain must come from the record as it exists inside the barrier" + ); + assert_eq!( + confirmed + .record + .previous_disk + .as_ref() + .expect("previous disk") + .content_digest, + "2".repeat(64) + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// Barrier refusals and validation refusals both release the session: + /// the snapshot directory and the session record are gone afterwards. + #[test] + fn failed_confirms_release_the_install_session() { + let root = unique_temp_root("confirm-cleanup"); + let install_root = root.join("plugins"); + let temporary_root = root.join("plugin-temp"); + let source = package_directory(&root, "1.0.0"); + + // A barrier that refuses: the session must be released. + let prepared = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-drain", + source.to_str().expect("source path"), + ) + .expect("prepare succeeds"); + let snapshot = temporary_root.join(&prepared.session_id); + assert!(snapshot.exists(), "the snapshot exists before the confirm"); + let error = crate::plugin_host::install::confirm_and_publish_in_barrier( + &install_root, + &temporary_root, + &prepared.session_id, + &prepared.manifest_digest, + &snapshot, + TEST_PLUGIN_ID, + &|| Err("another mutation is still draining".to_string()), + ) + .expect_err("a refusing barrier must fail the confirm"); + assert!(error.contains("draining"), "{error}"); + assert!( + !snapshot.exists(), + "the snapshot must be released after a barrier refusal" + ); + assert!( + !temporary_root + .join(format!("{}.source.json", prepared.session_id)) + .exists(), + "the session record must be released after a barrier refusal" + ); + + // A validation refusal (corrupted session record) releases too. + let prepared = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-corrupt", + source.to_str().expect("source path"), + ) + .expect("prepare succeeds"); + let snapshot = temporary_root.join(&prepared.session_id); + std::fs::write( + temporary_root.join(format!("{}.source.json", prepared.session_id)), + "{ not json", + ) + .expect("session record corrupted"); + assert!(crate::plugin_host::install::confirm_and_publish_in_barrier( + &install_root, + &temporary_root, + &prepared.session_id, + &prepared.manifest_digest, + &snapshot, + TEST_PLUGIN_ID, + &|| Ok(()), + ) + .is_err()); + assert!( + !snapshot.exists(), + "the snapshot must be released after a validation refusal" + ); + assert!( + !temporary_root + .join(format!("{}.source.json", prepared.session_id)) + .exists(), + "the session record must be released after a validation refusal" + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// Builds the interrupted mid-commit state: the old bytes sit in the + /// recovery backup, the interrupted new bytes occupy the version + /// directory, and the record still names the old version. + fn plant_interrupted_commit( + install_root: &std::path::Path, + replacement: &std::path::Path, + ) -> PathBuf { + let plugin_directory = install_root.join(TEST_PLUGIN_ID); + let version_directory = plugin_directory.join("1.0.0"); + let backup_directory = plugin_directory.join("1.0.0.previous-tmp"); + std::fs::rename(&version_directory, &backup_directory).expect("backup move"); + copy_directory_snapshot(replacement, &version_directory).expect("interrupted bytes"); + backup_directory + } + + /// A recovery backup left by a crashed transaction is restored before + /// the next publish replaces anything, and the completed transaction + /// leaves no backup behind. + #[test] + fn stale_recovery_backup_is_restored_before_the_next_publish() { + let root = unique_temp_root("recovery-restore"); + let install_root = root.join("plugins"); + let original = package_directory(&root, "1.0.0"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &original, + community_record(TEST_PLUGIN_ID, "1.0.0", &original), + ) + .expect("first install"); + let marker = install_root + .join(TEST_PLUGIN_ID) + .join("1.0.0/language-server/bin/intelephense.js"); + + let replacement = same_version_package(&root, "1.0.0", "replacement"); + std::fs::write( + replacement.join("language-server/bin/intelephense.js"), + b"replaced", + ) + .expect("replacement entry"); + let backup_directory = plant_interrupted_commit(&install_root, &replacement); + assert_eq!( + std::fs::read(&marker).expect("marker"), + b"replaced", + "the interrupted bytes occupy the version directory" + ); + assert_eq!( + std::fs::read(backup_directory.join("language-server/bin/intelephense.js")) + .expect("backup marker"), + b"entry", + "the backup holds the old bytes" + ); + + // The next legitimate publish recovers first, then completes. + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + community_record(TEST_PLUGIN_ID, "1.0.0", &replacement), + ) + .expect("publish recovers and completes"); + assert_eq!( + std::fs::read(&marker).expect("marker"), + b"replaced", + "the new transaction installs its own bytes" + ); + let plugin_directory = install_root.join(TEST_PLUGIN_ID); + let leftovers: Vec = std::fs::read_dir(&plugin_directory) + .expect("plugin directory readable") + .flatten() + .map(|entry| entry.file_name().to_string_lossy().to_string()) + .collect(); + assert!( + leftovers + .iter() + .all(|name| name == "1.0.0" || name == "installation.json"), + "the recovery backup must not survive a completed transaction: {leftovers:?}" + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// When the recovery cannot be restored, the backup is kept as the only + /// recoverable copy and the failure says so; releasing the lock lets the + /// next publish recover and complete. + #[cfg(windows)] + #[test] + fn recovery_backup_survives_a_failed_restore_until_the_retry() { + let root = unique_temp_root("recovery-keep"); + let install_root = root.join("plugins"); + let original = package_directory(&root, "1.0.0"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &original, + community_record(TEST_PLUGIN_ID, "1.0.0", &original), + ) + .expect("first install"); + + let replacement = same_version_package(&root, "1.0.0", "replacement"); + std::fs::write( + replacement.join("language-server/bin/intelephense.js"), + b"replaced", + ) + .expect("replacement entry"); + let backup_directory = plant_interrupted_commit(&install_root, &replacement); + + // Locking a file inside the backup keeps Windows from renaming the + // backup directory, so the recovery fails and the backup stays. + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; + let locked_file = backup_directory.join("language-server/bin/intelephense.js"); + let lock = std::fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .open(&locked_file) + .expect("backup file opened"); + + let error = publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + community_record(TEST_PLUGIN_ID, "1.0.0", &replacement), + ) + .expect_err("the recovery must fail while the backup is locked"); + drop(lock); + assert!(error.contains("recovery backup"), "{error}"); + assert!( + backup_directory.exists(), + "the recoverable bytes must be kept, never blindly deleted" + ); + + // The retry recovers the backup and completes the transaction. + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + community_record(TEST_PLUGIN_ID, "1.0.0", &replacement), + ) + .expect("the retry recovers and completes"); + assert_eq!( + std::fs::read( + install_root + .join(TEST_PLUGIN_ID) + .join("1.0.0/language-server/bin/intelephense.js"), + ) + .expect("marker"), + b"replaced" + ); + // Post-commit backup cleanup is best-effort in production: a leftover + // backup is the documented self-healing state (the next publish + // restores it before replacing anything) and must stay removable. + if backup_directory.exists() { + std::fs::remove_dir_all(&backup_directory) + .expect("a leftover backup stays removable once no handle holds it"); + } + let _ = std::fs::remove_dir_all(&root); + } + + // ---------- final-review regressions: backup recovery direction, + // backup path collisions, and identity checks before publish ---------- + + /// Exercise the backup produced by an actual replacement, rather than + /// planting one at the path the test assumes. An injected post-commit + /// cleanup failure retains that real backup; retrying must keep the new record + /// and new bytes together even when the next record commit fails. + #[cfg(windows)] + #[test] + fn real_replacement_backup_survives_cleanup_and_failed_retry() { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; + + let root = unique_temp_root("real-backup-retry"); + let install_root = root.join("plugins"); + let original = package_directory(&root, "1.0.0"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &original, + community_record(TEST_PLUGIN_ID, "1.0.0", &original), + ) + .expect("first install"); + let plugin_directory = install_root.join(TEST_PLUGIN_ID); + let marker = plugin_directory.join("1.0.0/language-server/bin/intelephense.js"); + let replacement = same_version_package(&root, "1.0.0", "approved-replacement"); + std::fs::write( + replacement.join("language-server/bin/intelephense.js"), + b"approved-new", + ) + .expect("replacement bytes"); + let replacement_record = community_record(TEST_PLUGIN_ID, "1.0.0", &replacement); + crate::plugin_host::install::publish_installed_version_with_backup_cleanup( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + replacement_record.clone(), + &|_| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "cleanup blocked", + )) + }, + ) + .expect("replacement commits despite cleanup failure"); + + let backup = plugin_directory.join("1.0.0.previous-tmp"); + assert_eq!( + std::fs::read(backup.join("language-server/bin/intelephense.js")) + .expect("the real transaction uses the complete version in its backup path"), + b"entry" + ); + assert!(!plugin_directory.join("1.0.previous-tmp").exists()); + assert_eq!( + std::fs::read(&marker).expect("active replacement"), + b"approved-new" + ); + + let record_path = plugin_directory.join("installation.json"); + let _record_lock = std::fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .open(&record_path) + .expect("block the next record commit"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &original, + community_record(TEST_PLUGIN_ID, "1.0.0", &original), + ) + .expect_err("the next record commit fails"); + assert_eq!( + std::fs::read(&marker).expect("restored active bytes"), + b"approved-new" + ); + let stored: InstallationRecord = + serde_json::from_slice(&std::fs::read(&record_path).expect("durable record")) + .expect("record parses"); + assert_eq!( + stored + .disk + .as_ref() + .expect("stored provenance") + .content_digest, + replacement_record + .disk + .as_ref() + .expect("approved provenance") + .content_digest + ); + assert!(!backup.exists()); + } + + /// A corrupt record cannot redirect recovery into another directory. + #[test] + fn recovery_refuses_record_paths_outside_the_owning_plugin() { + for forged_id in [true, false] { + let root = unique_temp_root("recovery-owner"); + let install_root = root.join("plugins"); + let package = package_directory(&root, "1.0.0"); + let mut record = community_record(TEST_PLUGIN_ID, "1.0.0", &package); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &package, + record.clone(), + ) + .expect("initial install"); + let plugin_directory = install_root.join(TEST_PLUGIN_ID); + let backup = plugin_directory.join("1.0.1.previous-tmp"); + std::fs::rename(plugin_directory.join("1.0.0"), &backup).expect("crash backup"); + if forged_id { + record.plugin_id = "../outside".to_string(); + } else { + record.version = "../../outside/1.0.0".to_string(); + } + let record_path = plugin_directory.join("installation.json"); + let forged_record = serde_json::to_vec(&record).expect("forged record bytes"); + std::fs::write(&record_path, &forged_record).expect("corrupted record"); + let outside = root.join("outside"); + std::fs::create_dir_all(&outside).expect("unrelated directory"); + std::fs::write(outside.join("keep.txt"), b"unrelated user data").expect("sentinel"); + let error = publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &package, + community_record(TEST_PLUGIN_ID, "1.0.0", &package), + ) + .expect_err("a record cannot redirect recovery outside its plugin"); + assert!(error.contains("recovery record"), "{error}"); + assert_eq!( + std::fs::read(&record_path).expect("unchanged record"), + forged_record + ); + assert!( + backup.join("plugin.json").is_file(), + "the backup stays in its own plugin" + ); + assert!( + !outside.join("1.0.0").exists(), + "recovery does not write outside the plugin" + ); + assert_eq!( + std::fs::read(outside.join("keep.txt")).expect("sentinel"), + b"unrelated user data" + ); + } + } + + /// Adjacent patch versions recover their own complete version names. + #[test] + fn adjacent_patch_versions_do_not_collide_on_backup_paths() { + let root = unique_temp_root("patch-collision"); + let install_root = root.join("plugins"); + let first = package_directory(&root, "1.0.0"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &first, + community_record(TEST_PLUGIN_ID, "1.0.0", &first), + ) + .expect("installs 1.0.0"); + + // Interrupt the 1.0.0 bytes into their own named backup. + let plugin_directory = install_root.join(TEST_PLUGIN_ID); + let backup_100 = plugin_directory.join("1.0.0.previous-tmp"); + std::fs::rename(plugin_directory.join("1.0.0"), &backup_100).expect("backup 1.0.0"); + + // Publishing 1.0.1 must restore the approved 1.0.0 bytes from the + // backup and then install 1.0.1, leaving both versions intact. The + // record carries the rollback chain confirm builds from the durable + // record. + let patch = package_directory(&root, "1.0.1"); + let mut patch_record = community_record(TEST_PLUGIN_ID, "1.0.1", &patch); + patch_record.previous_version = Some("1.0.0".to_string()); + patch_record.previous_origin = Some(InstallOrigin::Community); + patch_record.previous_disk = Some(DiskProvenance { + file_name: "first.zip".to_string(), + archive_sha256: None, + content_digest: signature::content_digest(&first).expect("digest"), + approved_programs: Vec::new(), + }); + publish_installed_version(&install_root, TEST_PLUGIN_ID, "1.0.1", &patch, patch_record) + .expect("publishing 1.0.1 recovers 1.0.0 first"); + assert!( + plugin_directory.join("1.0.0/plugin.json").is_file(), + "the approved 1.0.0 bytes are restored to their own directory" + ); + assert!(plugin_directory.join("1.0.1/plugin.json").is_file()); + assert!( + !backup_100.exists(), + "no backup survives a completed recovery" + ); + let stored: InstallationRecord = serde_json::from_str( &std::fs::read_to_string(plugin_directory.join("installation.json")).expect("record"), ) .expect("record parses"); - let previous = versions - .into_iter() - .find(|version| *version != record.version) - .expect("previous version"); - assert_eq!(previous, "1.5.0"); + assert_eq!(stored.version, "1.0.1"); + assert_eq!(stored.previous_version.as_deref(), Some("1.0.0")); + // The legacy with_extension naming would have collapsed both patch + // versions onto "1.0.previous-tmp"; it must never appear. + assert!(!plugin_directory.join("1.0.previous-tmp").exists()); + let _ = std::fs::remove_dir_all(&root); + } + + /// A committed transaction that could not delete its backup leaves a + /// stale copy; the next publish removes it instead of restoring old + /// bytes over the new record, and a later record-commit failure still + /// recovers the currently approved bytes. + #[test] + fn leftover_backup_after_a_commit_is_cleaned_and_the_retry_recovers() { + let root = unique_temp_root("stale-backup"); + let install_root = root.join("plugins"); + let original = package_directory(&root, "1.0.0"); + let original_record = community_record(TEST_PLUGIN_ID, "1.0.0", &original); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &original, + original_record.clone(), + ) + .expect("installs"); + let approved_digest = original_record + .disk + .as_ref() + .expect("disk") + .content_digest + .clone(); + let marker = install_root + .join(TEST_PLUGIN_ID) + .join("1.0.0/language-server/bin/intelephense.js"); + + // A stale leftover: bytes that the record does NOT approve. + let stale_backup = install_root.join(TEST_PLUGIN_ID).join("1.0.0.previous-tmp"); + std::fs::create_dir_all(&stale_backup).expect("stale backup dir"); + std::fs::write(stale_backup.join("stale.txt"), b"stale").expect("stale bytes"); + + let replacement = same_version_package(&root, "1.0.0", "replacement"); + std::fs::write( + replacement.join("language-server/bin/intelephense.js"), + b"replaced", + ) + .expect("replacement entry"); + + // Block the record commit of the next transaction. + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; + let record_path = install_root.join(TEST_PLUGIN_ID).join("installation.json"); + let lock = std::fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .open(&record_path) + .expect("record file opened"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + community_record(TEST_PLUGIN_ID, "1.0.0", &replacement), + ) + .expect_err("the record commit must fail while the record is locked"); + drop(lock); + + // The stale backup was removed (the approved bytes were intact), and + // the failed commit restored the approved bytes - not the stale ones. + assert_eq!( + std::fs::read(&marker).expect("marker"), + b"entry", + "the approved bytes are restored, never the stale backup" + ); + let stored: InstallationRecord = + serde_json::from_str(&std::fs::read_to_string(&record_path).expect("record")) + .expect("record parses"); + assert_eq!( + stored.disk.as_ref().expect("disk").content_digest, + approved_digest, + "the durable record still approves the restored bytes" + ); + assert!( + !stale_backup.exists(), + "the stale backup was cleaned before the transaction" + ); + + // The retry completes the replacement. + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + community_record(TEST_PLUGIN_ID, "1.0.0", &replacement), + ) + .expect("the retry completes"); + assert_eq!(std::fs::read(&marker).expect("marker"), b"replaced"); + let _ = std::fs::remove_dir_all(&root); + } + + /// A missing active directory with a backup present is judged by the + /// durable record's approved digest: matching bytes are restored, + /// non-matching bytes are refused with everything kept. + #[test] + fn missing_active_with_backup_restores_only_the_approved_bytes() { + let root = unique_temp_root("missing-active"); + let install_root = root.join("plugins"); + let original = package_directory(&root, "1.0.0"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &original, + community_record(TEST_PLUGIN_ID, "1.0.0", &original), + ) + .expect("installs"); + let plugin_directory = install_root.join(TEST_PLUGIN_ID); + let backup = plugin_directory.join("1.0.0.previous-tmp"); + std::fs::rename(plugin_directory.join("1.0.0"), &backup).expect("crash move"); + assert!(!plugin_directory.join("1.0.0").exists(), "active missing"); + + let replacement = same_version_package(&root, "1.0.0", "replacement"); + std::fs::write( + replacement.join("language-server/bin/intelephense.js"), + b"replaced", + ) + .expect("replacement entry"); + + // Ambiguous: the backup does not hold the approved bytes. The + // publish is refused and every byte is kept. + std::fs::write( + backup.join("language-server/bin/intelephense.js"), + b"unprovable", + ) + .expect("backup bytes changed"); + assert!(publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + community_record(TEST_PLUGIN_ID, "1.0.0", &replacement), + ) + .is_err()); + assert!( + backup.join("language-server/bin/intelephense.js").is_file(), + "the ambiguous backup is kept" + ); + assert!( + !plugin_directory.join("1.0.0").exists(), + "no bytes are invented for the missing active directory" + ); + + // Restore the approved content: the publish recovers and completes. + std::fs::write(backup.join("language-server/bin/intelephense.js"), b"entry") + .expect("approved bytes restored into the backup"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + community_record(TEST_PLUGIN_ID, "1.0.0", &replacement), + ) + .expect("the recovery completes the publish"); + assert_eq!( + std::fs::read(plugin_directory.join("1.0.0/language-server/bin/intelephense.js"),) + .expect("marker"), + b"replaced" + ); + assert!(!backup.exists(), "no backup survives the recovery"); + let _ = std::fs::remove_dir_all(&root); + } + + /// A malformed session fails during identity resolution, before the + /// barrier is ever taken. The command resolves the identity inside its + /// cleanup-guarded block, so this component regression pins both halves: + /// the resolution refuses with a diagnosable error, and the shared + /// release helper clears the snapshot and the session record. + #[test] + fn early_identity_failure_releases_the_session() { + let root = unique_temp_root("identity-early"); + let temporary_root = root.join("plugin-temp"); + let source = package_directory(&root, "1.0.0"); + let prepared = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-badjson", + source.to_str().expect("source path"), + ) + .expect("prepare succeeds"); + let snapshot = temporary_root.join(&prepared.session_id); + std::fs::write(snapshot.join("plugin.json"), "{ not json").expect("manifest corrupted"); + + // The command's first in-guard step refuses a malformed session. + let error = crate::plugin_host::install::session_plugin_id_for_tests( + &temporary_root, + &prepared.session_id, + ) + .expect_err("a malformed session must fail identity resolution"); + assert!(error.contains("plugin.json"), "{error}"); + + // Everything the confirm would have published is released. + crate::plugin_host::install::discard_install_session_for_tests( + &temporary_root, + &prepared.session_id, + &snapshot, + ); + assert!( + !snapshot.exists(), + "the snapshot is released by the early failure" + ); + assert!( + !temporary_root + .join(format!("{}.source.json", prepared.session_id)) + .exists(), + "the session record is released by the early failure" + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// If the session content changes to another plugin while the confirm + /// waits for the barrier, the identity check refuses before any bytes + /// are published under the wrong plugin's barrier. + #[test] + fn identity_change_is_refused_before_publish() { + let root = unique_temp_root("identity-swap"); + let install_root = root.join("plugins"); + let temporary_root = root.join("plugin-temp"); + let source = package_directory(&root, "1.0.0"); + let prepared = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-swap", + source.to_str().expect("source path"), + ) + .expect("prepare succeeds"); + let snapshot = temporary_root.join(&prepared.session_id); + + let barrier_taken = std::sync::Arc::new(std::sync::Mutex::new(false)); + let begin = { + let barrier_taken = barrier_taken.clone(); + move || -> Result<(), String> { + *barrier_taken.lock().expect("flag") = true; + Ok(()) + } + }; + // The expected identity is what the caller's barrier was taken for; + // the current session is otherwise fully valid and approved for B. + // Only comparing it to the actual barrier's A identity may reject + // this, rather than an earlier manifest-digest mismatch masking it. + let error = crate::plugin_host::install::confirm_and_publish_in_barrier( + &install_root, + &temporary_root, + &prepared.session_id, + &prepared.manifest_digest, + &snapshot, + "io.example.other-barrier-owner", + &begin, + ) + .expect_err("an identity change must be refused before publish"); + assert!(error.contains("changed during confirmation"), "{error}"); + assert!( + *barrier_taken.lock().expect("flag"), + "the barrier was taken for the prepared identity before the check" + ); + assert!( + !install_root.join(TEST_PLUGIN_ID).exists(), + "nothing is published for the prepared plugin" + ); + assert!( + !install_root.join("io.example.other-barrier-owner").exists(), + "nothing is published for the swapped-in plugin" + ); + assert!( + !snapshot.exists(), + "the failed session releases its snapshot" + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// The record shape survives serde round-trips with all provenance + /// fields intact; the scan and launch paths rely on this. + #[test] + fn installation_record_round_trips_provenance() { + let root = unique_temp_root("record-shape"); + let package = package_directory(&root, "1.0.0"); + let record = community_record(TEST_PLUGIN_ID, "1.0.0", &package); + let text = serde_json::to_string_pretty(&record).expect("record text"); + let parsed: InstallationRecord = serde_json::from_str(&text).expect("record parses"); + assert_eq!(parsed.schema_version, 2); + assert_eq!(parsed.origin, Some(InstallOrigin::Community)); + assert_eq!( + parsed.active_content_digest(), + record.active_content_digest() + ); + // A schema 1 record decodes without provenance; discovery quarantines + // it instead of trusting the enabled flag. + let legacy: InstallationRecord = serde_json::from_str(&format!( + r#"{{"schemaVersion": 1, "pluginId": "{TEST_PLUGIN_ID}", "version": "1.0.0"}}"# + )) + .expect("legacy record parses"); + assert_eq!(legacy.origin, None); + assert_eq!(legacy.active_content_digest(), None); + let _ = std::fs::remove_dir_all(&root); + } + + /// The write helper produces an atomic record the discovery path reads + /// back with its provenance. + #[test] + fn record_write_is_readable_by_discovery() { + let root = unique_temp_root("record-write"); + let install_root = root.join("plugins"); + let package = package_directory(&root, "1.0.0"); + let record = community_record(TEST_PLUGIN_ID, "1.0.0", &package); + publish_installed_version(&install_root, TEST_PLUGIN_ID, "1.0.0", &package, record) + .expect("published"); + let discovered = store::discover_installed_in(&install_root).expect("discovery works"); + assert_eq!(discovered.len(), 1); + assert!(discovered[0].record_problem.is_none()); + assert_eq!(discovered[0].origin, Some(InstallOrigin::Community)); + assert!(discovered[0].content_digest.is_some()); let _ = std::fs::remove_dir_all(&root); } } diff --git a/windows/tauri/src-tauri/src/plugin_host/launch.rs b/windows/tauri/src-tauri/src/plugin_host/launch.rs index b7dcebccb9..abd66266ab 100644 --- a/windows/tauri/src-tauri/src/plugin_host/launch.rs +++ b/windows/tauri/src-tauri/src/plugin_host/launch.rs @@ -19,7 +19,7 @@ use std::path::Path; use std::path::PathBuf; use std::process::Command; use std::time::{Duration, Instant}; -use tauri::{AppHandle, Manager}; +use tauri::{AppHandle, Manager, WebviewWindow}; /// Windows reparse-point attribute; covers symlinks and junctions alike. #[cfg(windows)] @@ -35,7 +35,7 @@ const MAX_RUNTIME_PROBE_OUTPUT_BYTES: usize = 64 * 1024; use std::os::windows::fs::MetadataExt; /// A resolved launch, shaped for `resolveEditorLspLaunch` on the frontend. -#[derive(Debug, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct ResolvedLaunch { pub plugin_id: String, @@ -54,6 +54,7 @@ pub struct ResolvedLaunch { #[tauri::command] pub fn declarative_plugins_language_launch( app: AppHandle, + window: WebviewWindow, file_path: String, workspace_path: String, ) -> Result, String> { @@ -63,8 +64,7 @@ pub fn declarative_plugins_language_launch( let _native_launch = app .state::() .acquire(&package.entry.plugin_id)?; - let package = package_by_id(&app, &package.entry.plugin_id) - .ok_or("plugin was removed during launch resolution")?; + let package = verified_package_by_id(&app, &package.entry.plugin_id)?; let Some(language_id) = owning_language_id(&package, &file_path) else { return Ok(None); }; @@ -92,7 +92,15 @@ pub fn declarative_plugins_language_launch( state_directory.join(store::workspace_state_key(&workspace_path)); prepare_required_directories(resolved_plan, &state_directory, &workspace_state_directory)?; std::fs::create_dir_all(&state_directory).map_err(|error| error.to_string())?; - resolve_launch(&package, &language_id, resolved_plan, &state_directory).map(Some) + let resolved = resolve_launch(&package, &language_id, resolved_plan, &state_directory)?; + super::provider::remember_language_launch( + &app, + window.label(), + &workspace_path, + &package, + &resolved, + )?; + Ok(Some(resolved)) } /// Finds an accepted, enabled scanned package by plugin ID. @@ -113,6 +121,39 @@ pub(crate) fn package_by_id(app: &AppHandle, plugin_id: &str) -> Option Result { + let package = package_by_id(app, plugin_id) + .ok_or_else(|| format!("{plugin_id} is not an accepted declarative plugin"))?; + let trust = package + .trust + .as_ref() + .expect("accepted packages carry trust evidence"); + // Bundled packages must still sit inside the real resource root; the + // launch path re-proves the seal instead of trusting the scan's word. + let bundled_root = store::bundled_root(app).ok(); + super::signature::verify_for_origin( + &package.root, + &package.entry.plugin_id, + &package.entry.version, + &package.manifest, + trust.origin, + trust.expected_content_digest.as_deref(), + bundled_root.as_deref(), + &super::signature::trusted_keys(), + ) + .map_err(|error| format!("{} trust check failed: {}", plugin_id, error.message()))?; + Ok(package) +} + /// Returns the enabled declarative plugin that owns a file, for the run and /// test entry points. Mirrors the launch path's ownership matching without /// expanding a plan. @@ -187,7 +228,7 @@ fn owning_language_id(package: &ScannedPackage, file_path: &str) -> Option Result { + let resolved = resolve_launch_passive(package, language_id, plan, probe_directory)?; + validate_resolved_launch(plan, &resolved, probe_directory)?; + Ok(resolved) +} + +/// Checks only the executables frozen by passive preparation, after consent. +pub(crate) fn validate_resolved_launch( + plan: &Value, + resolved: &ResolvedLaunch, + probe_directory: &Path, +) -> Result<(), String> { + validate_resolved_launch_checked(plan, resolved, probe_directory, || Ok(())) +} + +pub(crate) fn validate_resolved_launch_checked( + plan: &Value, + resolved: &ResolvedLaunch, + probe_directory: &Path, + checkpoint: impl Fn() -> Result<(), String>, +) -> Result<(), String> { + checkpoint()?; + let launch = &plan["launch"]; + if launch["kind"] == "runtime" { + verify_runtime_version( + Path::new(&resolved.server_path), + launch["tool"].as_str().ok_or("runtime has no tool")?, + launch, + probe_directory, + )?; + } + for requirement in plan["runtimeRequirements"].as_array().into_iter().flatten() { + checkpoint()?; + let tool = requirement["tool"] + .as_str() + .ok_or("runtime requirement has no tool")?; + let key = format!("LITHE_{}_PATH", tool.to_uppercase().replace('-', "_")); + let path = resolved + .environment + .get(&key) + .ok_or("resolved runtime is missing")?; + let mut version = requirement.clone(); + version["versionArguments"] = serde_json::json!(if tool == "java" { + vec!["-version"] + } else { + vec!["--version"] + }); + verify_runtime_version(Path::new(path), tool, &version, probe_directory)?; + } + let validation = string_arguments(&plan["validationArguments"]); + checkpoint()?; + if !validation.is_empty() { + let mut command = Command::new(&resolved.server_path); + command + .args(validation) + .envs(&resolved.environment) + .current_dir(probe_directory); + run_process_probe(&mut command, "language server validation", true)?; + } + checkpoint() +} + +pub(crate) fn resolve_launch_passive( package: &ScannedPackage, language_id: &str, plan: &Value, probe_directory: &Path, +) -> Result { + resolve_launch_passive_with_environment( + package, + language_id, + plan, + probe_directory, + &std::env::vars().collect(), + ) +} + +pub(crate) fn resolve_launch_passive_with_environment( + package: &ScannedPackage, + language_id: &str, + plan: &Value, + _probe_directory: &Path, + inherited: &BTreeMap, ) -> Result { for relative in string_arguments(&plan["requiredPackagePaths"]) { verified_package_resource(&package.root, &relative)?; @@ -251,6 +375,12 @@ fn resolve_launch( let launch = &plan["launch"]; let kind = launch["kind"].as_str().unwrap_or_default(); let mut environment: BTreeMap = BTreeMap::new(); + environment.insert( + "PATH".into(), + inherited_variable(inherited, "PATH") + .cloned() + .unwrap_or_default(), + ); let server_path = match kind { "packaged" => verified_packaged_path( &package.root, @@ -262,7 +392,7 @@ fn resolve_launch( let commands = launch["commands"] .as_array() .ok_or("systemCommand launch has no commands")?; - let path_env = std::env::var_os("PATH"); + let path_env = inherited_variable(inherited, "PATH").map(std::ffi::OsString::from); commands .iter() .filter_map(|command| command.as_str()) @@ -281,12 +411,16 @@ fn resolve_launch( let path = if let Some(packaged) = launch["packagedRuntime"].as_str() { // Packaged runtimes are fixed by the approved package. verified_packaged_path(&package.root, packaged)? - } else if let Some(explicit) = std::env::var_os(&key).filter(|value| !value.is_empty()) + } else if let Some(explicit) = inherited_variable(inherited, &key) + .map(std::ffi::OsString::from) + .filter(|value| !value.is_empty()) { PathBuf::from(explicit) } else { super::super::language_tools::find_system_tool( - std::env::var_os("PATH").as_deref(), + inherited_variable(inherited, "PATH") + .map(std::ffi::OsString::from) + .as_deref(), tool, ) .ok_or_else(|| format!("runtime tool {tool} is not on PATH"))? @@ -300,7 +434,7 @@ fn resolve_launch( prepend_path(&mut environment, directory); } environment.insert(key, path.to_string_lossy().to_string()); - verify_runtime_version(&path, tool, launch, probe_directory)?; + path } other => return Err(format!("unsupported launch kind {other}")), @@ -312,13 +446,16 @@ fn resolve_launch( let key = format!("LITHE_{}_PATH", tool.to_uppercase().replace('-', "_")); // Explicit user exports take precedence; project-local runtimes are // never selected by PATH discovery for automatic server starts. - let path = std::env::var_os(&key) + let path = inherited_variable(inherited, &key) + .map(std::ffi::OsString::from) .filter(|value| !value.is_empty()) .map(PathBuf::from) .or_else(|| environment.get(&key).map(PathBuf::from)) .or_else(|| { super::super::language_tools::find_system_tool( - std::env::var_os("PATH").as_deref(), + inherited_variable(inherited, "PATH") + .map(std::ffi::OsString::from) + .as_deref(), tool, ) }) @@ -328,27 +465,11 @@ fn resolve_launch( "runtime {tool} must name an absolute executable file" )); } - let mut version = requirement.clone(); - version["versionArguments"] = serde_json::json!(if tool == "java" { - vec!["-version"] - } else { - vec!["--version"] - }); - verify_runtime_version(&path, tool, &version, probe_directory)?; environment.insert(key, path.to_string_lossy().into_owned()); if let Some(directory) = path.parent() { prepend_path(&mut environment, directory); } } - let validation = string_arguments(&plan["validationArguments"]); - if !validation.is_empty() { - let mut command = Command::new(&server_path); - command - .args(&validation) - .envs(&environment) - .current_dir(probe_directory); - run_process_probe(&mut command, "language server validation", true)?; - } Ok(ResolvedLaunch { plugin_id: package.entry.plugin_id.clone(), language_id: language_id.to_string(), @@ -571,6 +692,36 @@ fn verified_package_resource(root: &Path, relative: &str) -> Result( + environment: &'a BTreeMap, + name: &str, +) -> Option<&'a String> { + #[cfg(windows)] + { + environment + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(name)) + .map(|(_, value)| value) + } + #[cfg(not(windows))] + { + environment.get(name) + } +} + +/// Apply Windows environment overrides without retaining differently cased +/// inherited duplicates such as Path and PATH. +pub(crate) fn merge_environment( + environment: &mut BTreeMap, + extra: BTreeMap, +) { + for (key, value) in extra { + #[cfg(windows)] + environment.retain(|existing, _| !existing.eq_ignore_ascii_case(&key)); + environment.insert(key, value); + } +} + fn prepend_path(environment: &mut BTreeMap, directory: &Path) { let existing = environment .get("PATH") @@ -748,6 +899,70 @@ mod tests { assert_eq!(resolved.environment["LITHE_NODE_PATH"], expected); } + #[test] + fn passive_provider_resolution_freezes_runtime_without_version_or_validation_probes() { + struct TempRoot(PathBuf); + impl Drop for TempRoot { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).unwrap(); + } + } + let root = TempRoot(unique_temp_root("passive-provider-runtime")); + let runtime = root.0.join("runtime.exe"); + std::fs::write(&runtime, b"a fixture file that must never be executed").unwrap(); + let package = package_with_root(root.0.clone(), serde_json::json!({})); + let plan = serde_json::json!({"launch":{"kind":"runtime","tool":"node","minimumVersion":"999","versionArguments":["--version"]}, + "arguments":["provider.js","--stdio"],"runtimeRequirements":[{"tool":"node","minimumVersion":"999"}],"validationArguments":["--validate"]}); + let frozen = BTreeMap::from([ + ("PATH".into(), String::new()), + ( + (if cfg!(windows) { + "lithe_node_path" + } else { + "LITHE_NODE_PATH" + }) + .into(), + runtime.to_string_lossy().into_owned(), + ), + ]); + let resolved = + resolve_launch_passive_with_environment(&package, "fixture", &plan, &root.0, &frozen) + .unwrap(); + assert_eq!(resolved.server_path, runtime.to_string_lossy()); + assert_eq!(resolved.server_args, vec!["provider.js", "--stdio"]); + assert_eq!( + resolved.environment["LITHE_NODE_PATH"], + runtime.to_string_lossy() + ); + assert_eq!( + std::fs::read(runtime).unwrap(), + b"a fixture file that must never be executed" + ); + } + + #[cfg(windows)] + #[test] + fn frozen_provider_environment_resolves_windows_case_aliases_once() { + let mut environment = BTreeMap::from([ + ("Path".into(), "old-path".into()), + ("java_home".into(), "old-home".into()), + ]); + assert_eq!( + inherited_variable(&environment, "PATH").map(String::as_str), + Some("old-path") + ); + merge_environment( + &mut environment, + BTreeMap::from([ + ("PATH".into(), "approved-path".into()), + ("JAVA_HOME".into(), "approved-home".into()), + ]), + ); + assert_eq!(environment.len(), 2); + assert_eq!(environment["PATH"], "approved-path"); + assert_eq!(environment["JAVA_HOME"], "approved-home"); + } + #[test] fn validation_requires_zero_exit_even_when_probe_prints_output() { run_process_probe(&mut probe_fixture_command("output"), "validation", true).unwrap(); @@ -820,6 +1035,7 @@ mod tests { }, root, manifest, + trust: None, } } diff --git a/windows/tauri/src-tauri/src/plugin_host/lifecycle.rs b/windows/tauri/src-tauri/src/plugin_host/lifecycle.rs index b2affc9fde..821d820ad1 100644 --- a/windows/tauri/src-tauri/src/plugin_host/lifecycle.rs +++ b/windows/tauri/src-tauri/src/plugin_host/lifecycle.rs @@ -121,6 +121,10 @@ impl Drop for Mutation { } } impl Mutation { + pub(crate) fn plugin_id(&self) -> &str { + &self.plugin_id + } + pub fn complete(self, active: bool) { self.app .state::() @@ -160,8 +164,6 @@ pub async fn begin(app: &AppHandle, plugin_id: &str) -> Result operation_id: operation_id.clone(), }; state.revoke(plugin_id); - state.wait_native(plugin_id).await?; - super::web::close_plugin_windows(app, plugin_id)?; let mut senders = HashMap::new(); let mut receivers = Vec::new(); for label in app @@ -189,6 +191,19 @@ pub async fn begin(app: &AppHandle, plugin_id: &str) -> Result ) .map_err(|error| error.to_string())?; } + // Tell UI owners to cancel first: their in-flight native operations also + // hold leases. Waiting for those leases before dispatching cancellation + // deadlocks a reload behind work that only the owner can cancel. + super::project_generators::close_plugin(app, plugin_id) + .await + .map_err(|error| error.code)?; + super::vcs::plugin_changing(app, plugin_id).await?; + super::functions::close_plugin(app, plugin_id).await?; + super::provider::close_plugin(app, plugin_id) + .await + .map_err(|error| error.detail)?; + super::web::close_plugin_windows(app, plugin_id)?; + state.wait_native(plugin_id).await?; await_drained(receivers, std::time::Duration::from_secs(45)).await?; Ok(mutation) } @@ -266,6 +281,36 @@ mod tests { waiting.await.unwrap(); } + #[tokio::test] + async fn failed_global_stop_retains_the_old_lease_and_retry_does_not_revoke_other_plugins() { + let state = LifecycleState::default(); + let old_owner = state.acquire("target.plugin").unwrap(); + let other_owner = state.acquire("other.plugin").unwrap(); + state.revoke("target.plugin"); + let (failed, failed_result) = oneshot::channel(); + failed.send(Err("process still running".into())).unwrap(); + assert!(await_drained( + vec![("first".into(), failed_result)], + std::time::Duration::from_secs(1) + ) + .await + .is_err()); + assert!(state.blocked("target.plugin")); + assert!(state.acquire("target.plugin").is_err()); + assert!(state.acquire("other.plugin").is_ok()); + let retry = state.wait_native("target.plugin"); + tokio::pin!(retry); + tokio::select! { biased; + result = &mut retry => panic!("failed stop discarded its old lease: {result:?}"), + _ = std::future::ready(()) => {}, + } + drop(old_owner); + // The unrelated owner's lease cannot hold up the target retry. + retry.await.unwrap(); + assert!(state.blocked("target.plugin")); + drop(other_owner); + } + #[tokio::test] async fn mutation_waits_for_every_window_and_propagates_stop_failure() { let (first, first_result) = oneshot::channel(); diff --git a/windows/tauri/src-tauri/src/plugin_host/marketplace.rs b/windows/tauri/src-tauri/src/plugin_host/marketplace.rs new file mode 100644 index 0000000000..4d0684a4ec --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/marketplace.rs @@ -0,0 +1,230 @@ +//! Native marketplace IPC. Only product windows can call these commands. Source +//! identities, platform, capabilities, filesystem roots and signing policy stay +//! native; the frontend supplies opaque operation and selected source/plugin IDs. + +use super::marketplace_coordinator::{ + MarketplaceCoordinator, MarketplaceHost, MarketplaceReview, SourceIndex, +}; +use super::marketplace_download::{FetchPolicy, ReqwestTransport}; +use super::marketplace_sources::{self, MarketplaceSource}; +use serde_json::Value; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Manager, WebviewWindow}; + +#[derive(Default)] +pub struct MarketplaceState( + Mutex>>, + Mutex>, +); + +struct NativeHost(AppHandle); + +impl MarketplaceHost for NativeHost { + fn sources(&self) -> Result, String> { + marketplace_sources::load_sources(&marketplace_sources::sources_path(&self.0)?) + .map(|(sources, _)| sources) + } + fn platform(&self) -> String { + match std::env::consts::ARCH { + "aarch64" => "windows-arm64", + "x86_64" => "windows-x64", + _ => "windows-unsupported", + } + .into() + } + fn capabilities(&self) -> Value { + super::host_capabilities() + } + fn ensure_window(&self, window: &str) -> Result<(), String> { + if self + .0 + .state::() + .1 + .lock() + .map_err(|_| "marketplace window state poisoned")? + .contains(window) + || self.0.get_webview_window(window).is_none() + { + return Err("the marketplace window was closed".into()); + } + Ok(()) + } + fn ensure_installable(&self, plugin_id: &str) -> Result<(), String> { + if self + .0 + .state::() + .packages() + .iter() + .any(|package| { + package.entry.plugin_id == plugin_id && package.entry.source == "bundled" + }) + { + return Err("bundled plugins cannot be replaced through the marketplace".into()); + } + Ok(()) + } + fn with_source( + &self, + expected: &MarketplaceSource, + publish: &mut dyn FnMut() -> Result<(), String>, + ) -> Result<(), String> { + let _guard = marketplace_sources::sources_lock() + .lock() + .map_err(|_| "marketplace sources poisoned")?; + let (sources, _) = marketplace_sources::load_sources_unlocked( + &marketplace_sources::sources_path(&self.0)?, + )?; + if !sources + .iter() + .any(|source| source == expected && source.is_enabled) + { + return Err("the marketplace source changed before confirmation".into()); + } + publish() + } +} + +fn coordinator(app: &AppHandle) -> Result, String> { + let state = app.state::(); + let mut coordinator = state.0.lock().map_err(|_| "marketplace state poisoned")?; + if let Some(coordinator) = coordinator.as_ref() { + return Ok(coordinator.clone()); + } + // All mutable files use platform-owned app data. The index cache stays under + // the source store's existing runtime root; downloads/snapshots reuse M1's + // plugin-temp cleanup scope. There is no shared worktree cache or bundle IO. + let root = marketplace_sources::sources_path(app)? + .parent() + .ok_or("source store root")? + .to_path_buf(); + let created = Arc::new(MarketplaceCoordinator::new( + Arc::new(NativeHost(app.clone())), + Arc::new(ReqwestTransport::new()?), + FetchPolicy::https_only(), + root, + super::install::snapshot_root(app)?, + super::store::install_root(app)?, + )); + *coordinator = Some(created.clone()); + Ok(created) +} + +fn existing(app: &AppHandle) -> Option> { + let state = app.try_state::()?; + let coordinator = state.0.lock().ok()?.clone(); + coordinator +} + +fn product_window(window: &WebviewWindow) -> Result { + let label = window.label(); + if label != "main" && !label.starts_with("workspace-") { + return Err("marketplace commands are restricted to product windows".into()); + } + if window.app_handle().get_webview_window(label).is_none() { + return Err("the marketplace window was closed".into()); + } + Ok(label.into()) +} + +#[tauri::command] +pub async fn declarative_marketplace_list( + window: WebviewWindow, +) -> Result, String> { + product_window(&window)?; + let coordinator = coordinator(window.app_handle())?; + tauri::async_runtime::spawn_blocking(move || coordinator.list()) + .await + .map_err(|error| error.to_string())? +} + +#[tauri::command] +pub async fn declarative_marketplace_refresh( + window: WebviewWindow, + operation_id: String, + source_id: String, +) -> Result { + let owner = product_window(&window)?; + coordinator(window.app_handle())? + .refresh(&owner, &operation_id, &source_id) + .await +} + +#[tauri::command] +pub async fn declarative_marketplace_prepare( + window: WebviewWindow, + operation_id: String, + source_id: String, + plugin_id: String, +) -> Result { + let owner = product_window(&window)?; + coordinator(window.app_handle())? + .prepare(&owner, &operation_id, &source_id, &plugin_id) + .await +} + +#[tauri::command] +pub async fn declarative_marketplace_confirm( + window: WebviewWindow, + operation_id: String, + manifest_digest: String, + approved: bool, +) -> Result { + let owner = product_window(&window)?; + let app = window.app_handle().clone(); + let coordinator = coordinator(&app)?; + let confirmation = coordinator.claim_confirmation(&owner, &operation_id)?; + confirmation.check_review(&manifest_digest, approved)?; + let plugin_id = confirmation.plugin_id().to_string(); + async move { + // Reuse the same process/webview drain barrier as every M1 mutation. + let mutation = super::lifecycle::begin(&app, &plugin_id).await?; + let worker_app = app.clone(); + // The worker owns the barrier until publication and rescan finish. If an + // IPC future is dropped, another mutation must not overlap that worker. + tauri::async_runtime::spawn_blocking(move || { + let confirmed = confirmation.publish(&manifest_digest, approved)?; + super::install::rescan_after_change(&worker_app)?; + let enabled = confirmed.record.enabled; + mutation.complete(enabled); + Ok::<_, String>(serde_json::json!({"pluginId": confirmed.plugin_id, "version": confirmed.version, "enabled": enabled})) + }).await.map_err(|error| error.to_string())? + }.await +} + +#[tauri::command] +pub fn declarative_marketplace_cancel( + window: WebviewWindow, + operation_id: String, +) -> Result<(), String> { + let owner = product_window(&window)?; + // Cancellation may be the first IPC polled. Create the owner now so its + // cancellation history also rejects a prepare that has not registered yet. + coordinator(window.app_handle())?.cancel(&owner, &operation_id); + Ok(()) +} + +pub(crate) fn window_destroyed(app: &AppHandle, window: &str) { + if window != "main" && !window.starts_with("workspace-") { + return; + } + if let Some(state) = app.try_state::() { + state + .1 + .lock() + .expect("marketplace windows") + .insert(window.into()); + } + if let Some(coordinator) = existing(app) { + coordinator.cancel_window(window); + } +} +pub(crate) fn cancel_source(app: &AppHandle, source: &str) { + if let Some(coordinator) = existing(app) { + coordinator.cancel_source(source); + } +} +pub(crate) fn cancel_plugin(app: &AppHandle, plugin: &str) { + if let Some(coordinator) = existing(app) { + coordinator.cancel_plugin(plugin); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/marketplace_coordinator.rs b/windows/tauri/src-tauri/src/plugin_host/marketplace_coordinator.rs new file mode 100644 index 0000000000..12b87918d2 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/marketplace_coordinator.rs @@ -0,0 +1,871 @@ +//! Window-owned marketplace operations. Core owns availability and install review; +//! M1 owns snapshots, signatures, staging and the installation transaction. This +//! adapter supplies only trusted source identity, cancellation and lifetime guards. + +use super::install::{self, MarketplaceInstallSource, PreparedInstall}; +use super::marketplace_download::{ + download_artifact, CancelToken, FetchLimits, FetchPolicy, HttpTransport, MAX_ARTIFACT_BYTES, + MAX_REDIRECT_HOPS, +}; +use super::marketplace_index::{ + availability_of, Availability, IndexRefreshRequest, MarketplaceIndexStore, SourceKind, +}; +use super::marketplace_sources::{MarketplaceSource, OFFICIAL_INDEX_URL, OFFICIAL_SOURCE_ID}; +use serde::Serialize; +use serde_json::Value; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +const FETCH_DEADLINE: Duration = Duration::from_secs(60); +const MAX_WINDOW_OPERATIONS: usize = 32; +const MAX_WINDOW_CANCELLATIONS: usize = 256; + +/// Platform-owned live state. No URL, path, capability or official flag is +/// accepted from IPC. The source transaction excludes simultaneous removal or +/// disable while committing an installation. +pub(crate) trait MarketplaceHost: Send + Sync { + fn sources(&self) -> Result, String>; + fn platform(&self) -> String; + fn capabilities(&self) -> Value; + fn ensure_window(&self, window: &str) -> Result<(), String>; + fn ensure_installable(&self, plugin_id: &str) -> Result<(), String>; + fn with_source( + &self, + source: &MarketplaceSource, + publish: &mut dyn FnMut() -> Result<(), String>, + ) -> Result<(), String>; +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SourceIndex { + pub source_id: String, + pub source_name: String, + pub index: Option, + pub problem: Option, + pub installations: std::collections::BTreeMap, +} + +/// Native installed-source ownership paired with Core availability. It describes +/// eligibility to prepare, never package trust or permission to skip confirmation. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallationState { + pub origin: &'static str, + pub version: Option, + pub enabled: Option, + pub source_id: Option, + pub can_prepare: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MarketplaceReview { + pub operation_id: String, + pub source_id: String, + pub source_name: String, + pub index_url: String, + /// True only for the host-pinned official source and a verified signed package. + pub official_publisher_verified: bool, + pub requires_confirmation: bool, + pub prepared: PreparedInstall, +} + +struct Session { + root: PathBuf, + id: String, +} +impl Session { + fn snapshot(&self) -> Result { + install::validated_session_path(&self.root, &self.id) + } +} +impl Drop for Session { + fn drop(&mut self) { + if let Ok(snapshot) = self.snapshot() { + install::discard_install_session(&self.root, &self.id, &snapshot); + } + } +} + +struct ReadyInstall { + session: Session, + source: MarketplaceInstallSource, + prepared: PreparedInstall, + requires_confirmation: bool, + index_epoch: u64, +} + +#[derive(Default)] +struct IndexEpoch { + value: u64, + refreshing: usize, +} + +struct RefreshOwner<'a> { + coordinator: &'a MarketplaceCoordinator, + source: String, +} +impl Drop for RefreshOwner<'_> { + fn drop(&mut self) { + if let Some(epoch) = self + .coordinator + .index_epochs + .lock() + .expect("marketplace epochs") + .get_mut(&self.source) + { + epoch.refreshing -= 1; + } + } +} + +struct Operation { + window: String, + source: MarketplaceSource, + plugin: Option, + cancel: CancelToken, + ready: Mutex>, + confirming: AtomicBool, +} + +#[derive(Default)] +struct OperationRegistry { + active: HashMap<(String, String), Arc>, + /// Covers cancel IPC arriving before the corresponding async command is + /// first polled. IDs are unique per request; retain recent cancellations for + /// one full operation deadline with a bounded per-window history. + cancelled: HashMap<(String, String), Instant>, + closed_windows: std::collections::HashSet, +} + +/// Covers dropped IPC futures as well as ordinary errors. A successful prepare +/// transfers ownership to the review registry; all other exits cancel workers. +struct RunningOperation<'a> { + coordinator: &'a MarketplaceCoordinator, + id: &'a str, + operation: Arc, + keep_review: bool, +} + +/// A single confirmation owns the review across the async lifecycle barrier and +/// the blocking publication worker. Dropping either phase cancels only this exact +/// operation, including when its public ID has since been reused by a new request. +pub(crate) struct Confirmation { + coordinator: Arc, + operation: Arc, + id: String, + plugin_id: String, +} + +impl Confirmation { + pub fn plugin_id(&self) -> &str { + &self.plugin_id + } + + /// Reject an unapproved, stale or wrong-digest review before the lifecycle + /// barrier stops a currently running installation. Publication repeats all + /// authoritative checks against live snapshot and installed-record bytes. + pub fn check_review(&self, digest: &str, approved: bool) -> Result<(), String> { + self.coordinator.check(&self.operation)?; + let slot = self + .operation + .ready + .lock() + .map_err(|_| "marketplace review poisoned")?; + let ready = slot + .as_ref() + .ok_or("the marketplace review is no longer ready")?; + if ready.prepared.manifest_digest != digest { + return Err("the review digest does not match".into()); + } + if ready.requires_confirmation && !approved { + return Err( + "explicit approval is required for the permissions and new programs".into(), + ); + } + let epochs = self + .coordinator + .index_epochs + .lock() + .map_err(|_| "marketplace epochs poisoned")?; + let epoch = epochs + .get(&ready.source.source_id) + .ok_or("the selected index expired")?; + if epoch.value != ready.index_epoch || epoch.refreshing != 0 { + return Err("the source index changed; prepare a new marketplace review".into()); + } + Ok(()) + } + + pub fn publish( + self, + digest: &str, + approved: bool, + ) -> Result { + self.coordinator + .confirm_claimed(&self.operation, digest, approved) + } +} + +impl Drop for Confirmation { + fn drop(&mut self) { + self.operation.cancel.cancel(); + self.operation + .ready + .lock() + .expect("marketplace review") + .take(); + self.coordinator.finish(&self.id, &self.operation); + } +} +impl Drop for RunningOperation<'_> { + fn drop(&mut self) { + if !self.keep_review { + self.operation.cancel.cancel(); + self.coordinator.finish(self.id, &self.operation); + } + } +} + +/// Runtime caches stay under the host's app-local marketplace root; snapshots and +/// downloads reuse M1's plugin-temp root. Neither is a worktree/build resource or +/// installed application resource, and neither affects signatures or delta bases. +pub(crate) struct MarketplaceCoordinator { + host: Arc, + transport: Arc, + policy: FetchPolicy, + indexes: MarketplaceIndexStore, + temporary_root: PathBuf, + install_root: PathBuf, + operations: Mutex, + index_epochs: Mutex>, +} + +impl MarketplaceCoordinator { + pub fn new( + host: Arc, + transport: Arc, + policy: FetchPolicy, + cache_root: PathBuf, + temporary_root: PathBuf, + install_root: PathBuf, + ) -> Self { + Self { + host, + transport, + policy, + indexes: MarketplaceIndexStore::new(cache_root), + temporary_root, + install_root, + operations: Mutex::new(OperationRegistry::default()), + index_epochs: Mutex::new(HashMap::new()), + } + } + + fn request(&self, source: &MarketplaceSource) -> IndexRefreshRequest { + IndexRefreshRequest { + source_id: source.id.clone(), + index_url: source.index_url.clone(), + source_kind: if official_source(source) { + SourceKind::Official + } else { + SourceKind::ThirdParty + }, + platform: self.host.platform(), + host_capabilities: self.host.capabilities(), + deadline: FETCH_DEADLINE, + } + } + + fn current_source(&self, source_id: &str) -> Result { + self.host + .sources()? + .into_iter() + .find(|source| source.id == source_id && source.is_enabled) + .ok_or_else(|| "the marketplace source was removed or disabled".into()) + } + + fn check(&self, operation: &Operation) -> Result<(), String> { + if operation.cancel.is_cancelled() { + return Err("marketplace operation cancelled".into()); + } + self.host.ensure_window(&operation.window)?; + if self.current_source(&operation.source.id)? != operation.source { + return Err("the marketplace source changed during this operation".into()); + } + Ok(()) + } + + fn begin( + &self, + window: &str, + id: &str, + source_id: &str, + plugin: Option, + ) -> Result, String> { + if id.is_empty() + || id.len() > 128 + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err("invalid marketplace operation id".into()); + } + let source = self.current_source(source_id)?; + let mut operations = self + .operations + .lock() + .map_err(|_| "marketplace operations poisoned")?; + let key = (window.to_string(), id.to_string()); + if operations.closed_windows.contains(window) { + return Err("the marketplace window was closed".into()); + } + operations + .cancelled + .retain(|_, cancelled| cancelled.elapsed() < FETCH_DEADLINE); + if operations.cancelled.contains_key(&key) { + return Err("this marketplace operation was cancelled before it started".into()); + } + if operations.active.contains_key(&key) + || operations + .active + .keys() + .filter(|(owner, _)| owner == window) + .count() + >= MAX_WINDOW_OPERATIONS + { + return Err("marketplace operation already exists or this window has too many pending operations".into()); + } + let operation = Arc::new(Operation { + window: window.into(), + source, + plugin, + cancel: CancelToken::new(), + ready: Mutex::new(None), + confirming: AtomicBool::new(false), + }); + operations.active.insert(key, operation.clone()); + drop(operations); + // Register before checking live ownership. A destroy notification that + // precedes registration is caught here; one arriving afterwards cancels + // the registered token. Lazy coordinator creation cannot miss teardown. + if let Err(error) = self.check(&operation) { + operation.cancel.cancel(); + self.finish(id, &operation); + return Err(error); + } + Ok(operation) + } + + fn finish(&self, id: &str, operation: &Arc) { + let mut operations = self.operations.lock().expect("marketplace operations"); + let key = (operation.window.clone(), id.into()); + if operations + .active + .get(&key) + .is_some_and(|current| Arc::ptr_eq(current, operation)) + { + operations.active.remove(&key); + } + } + + pub fn cancel(&self, window: &str, id: &str) { + if id.is_empty() + || id.len() > 128 + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return; + } + let operation = { + let mut operations = self.operations.lock().expect("marketplace operations"); + let key = (window.to_string(), id.to_string()); + operations + .cancelled + .retain(|_, cancelled| cancelled.elapsed() < FETCH_DEADLINE); + if operations + .cancelled + .keys() + .filter(|(owner, _)| owner == window) + .count() + >= MAX_WINDOW_CANCELLATIONS + { + if let Some(oldest) = operations + .cancelled + .iter() + .filter(|((owner, _), _)| owner == window) + .min_by_key(|(_, time)| **time) + .map(|(key, _)| key.clone()) + { + operations.cancelled.remove(&oldest); + } + } + operations.cancelled.insert(key.clone(), Instant::now()); + operations.active.remove(&key) + }; + if let Some(operation) = operation { + operation.cancel.cancel(); + operation.ready.lock().expect("marketplace review").take(); + } + } + pub fn cancel_window(&self, window: &str) { + self.cancel_matching(|key, _| key.0 == window, Some(window)); + } + pub fn cancel_source(&self, source: &str) { + self.cancel_matching(|_, operation| operation.source.id == source, None); + } + pub fn cancel_plugin(&self, plugin: &str) { + self.cancel_matching( + |_, operation| operation.plugin.as_deref() == Some(plugin), + None, + ); + } + + fn cancel_matching( + &self, + matches: impl Fn(&(String, String), &Operation) -> bool, + closed_window: Option<&str>, + ) { + let removed: Vec<_> = { + let mut operations = self.operations.lock().expect("marketplace operations"); + if let Some(window) = closed_window { + operations.closed_windows.insert(window.into()); + operations.cancelled.retain(|(owner, _), _| owner != window); + } + let keys: Vec<_> = operations + .active + .iter() + .filter(|(key, operation)| matches(key, operation)) + .map(|(key, _)| key.clone()) + .collect(); + keys.into_iter() + .filter_map(|key| operations.active.remove(&key)) + .collect() + }; + // Never hold the registry lock while waiting for a publication boundary. + for operation in removed { + operation.cancel.cancel(); + operation.ready.lock().expect("marketplace review").take(); + } + } + + pub fn list(&self) -> Result, String> { + self.host + .sources()? + .into_iter() + .filter(|source| source.is_enabled) + .map(|source| { + let result = self.indexes.cached_index(&self.request(&source)); + let (index, problem) = match result { + Ok(index) => (index, None), + Err(error) => (None, Some(error)), + }; + let installations = match index.as_ref() { + Some(index) => index["plugins"] + .as_array() + .into_iter() + .flatten() + .filter_map(|plugin| { + plugin["id"].as_str().map(|id| { + ( + id.to_string(), + self.installation_state( + &source, + id, + plugin["availability"]["status"] == "available", + ), + ) + }) + }) + .collect(), + None => std::collections::BTreeMap::new(), + }; + Ok(SourceIndex { + source_id: source.id, + source_name: source.name, + index, + problem, + installations, + }) + }) + .collect() + } + + fn installation_state( + &self, + source: &MarketplaceSource, + plugin_id: &str, + available: bool, + ) -> InstallationState { + let mut state = InstallationState { + origin: "none", + version: None, + enabled: None, + source_id: None, + can_prepare: available, + }; + if self.host.ensure_installable(plugin_id).is_err() { + state.origin = "bundled"; + state.can_prepare = false; + return state; + } + let directory = self.install_root.join(plugin_id); + let bytes = match std::fs::read(directory.join("installation.json")) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound && !directory.exists() => { + return state + } + Err(_) => { + state.origin = "unknown"; + state.can_prepare = false; + return state; + } + }; + let record = match serde_json::from_slice::(&bytes) { + Ok(record) + if record.schema_version == 2 + && record.plugin_id == plugin_id + && record.active_content_digest().is_some() + && matches!( + record.origin, + Some( + super::signature::InstallOrigin::Community + | super::signature::InstallOrigin::Marketplace + ) + ) => + { + record + } + _ => { + state.origin = "unknown"; + state.can_prepare = false; + return state; + } + }; + state.version = Some(record.version); + state.enabled = Some(record.enabled); + if let Some(marketplace) = record.marketplace { + state.origin = "marketplace"; + state.can_prepare &= + marketplace.source_id == source.id && marketplace.index_url == source.index_url; + state.source_id = Some(marketplace.source_id); + } else { + state.origin = "disk"; + state.can_prepare = false; + } + state + } + + pub async fn refresh(&self, window: &str, id: &str, source_id: &str) -> Result { + let operation = self.begin(window, id, source_id, None)?; + { + let mut epochs = self + .index_epochs + .lock() + .map_err(|_| "marketplace epochs poisoned")?; + let epoch = epochs.entry(source_id.into()).or_default(); + epoch.value += 1; + epoch.refreshing += 1; + } + let _refresh_owner = RefreshOwner { + coordinator: self, + source: source_id.into(), + }; + let _running = RunningOperation { + coordinator: self, + id, + operation: operation.clone(), + keep_review: false, + }; + let result = async { + let result = self + .indexes + .refresh( + self.transport.as_ref(), + &self.request(&operation.source), + self.policy, + &operation.cancel, + ) + .await + .map_err(|error| error.message())?; + self.check(&operation)?; + // A superseded refresh may answer its caller, but expose the newest + // cached view so a late IPC result cannot regress a market list. + if !result.persisted { + return self + .indexes + .cached_index(&self.request(&operation.source))? + .ok_or("the index refresh was superseded".into()); + } + Ok(result.index) + } + .await; + self.finish(id, &operation); + result + } + + pub async fn prepare( + self: &Arc, + window: &str, + id: &str, + source_id: &str, + plugin_id: &str, + ) -> Result { + let operation = self.begin(window, id, source_id, Some(plugin_id.into()))?; + let mut running = RunningOperation { + coordinator: self, + id, + operation: operation.clone(), + keep_review: false, + }; + let result = self + .prepare_operation(id, operation.clone(), plugin_id) + .await; + running.keep_review = result.is_ok(); + result + } + + async fn prepare_operation( + self: &Arc, + id: &str, + operation: Arc, + plugin_id: &str, + ) -> Result { + self.host.ensure_installable(plugin_id)?; + let (index_epoch, index) = { + let mut epochs = self + .index_epochs + .lock() + .map_err(|_| "marketplace epochs poisoned")?; + let epoch = epochs.entry(operation.source.id.clone()).or_default(); + if epoch.refreshing != 0 { + return Err( + "wait for this source refresh to finish before preparing an installation" + .into(), + ); + } + let index = self + .indexes + .cached_index(&self.request(&operation.source))? + .ok_or("refresh this source before preparing an installation")?; + (epoch.value, index) + }; + let (version, archive_url, archive_sha256) = match availability_of(&index, plugin_id) { + Availability::Available { + version, + download_url, + sha256, + } => (version, download_url, sha256), + Availability::Unsupported { code, message } => { + return Err(format!("{code}: {message}")) + } + Availability::NotFound => return Err("the plugin is not offered by this source".into()), + }; + let source = MarketplaceInstallSource { + source_id: operation.source.id.clone(), + source_name: operation.source.name.clone(), + index_url: operation.source.index_url.clone(), + repository: operation.source.repository.clone().unwrap_or_default(), + archive_url: archive_url.clone(), + archive_sha256: archive_sha256.clone(), + plugin_id: plugin_id.into(), + version, + official: official_source(&operation.source), + }; + install::marketplace_previous_record(&self.install_root, &source)?; + let artifact = download_artifact( + &self.temporary_root, + self.transport.as_ref(), + &archive_url, + &archive_sha256, + FetchLimits { + max_bytes: MAX_ARTIFACT_BYTES, + max_redirects: MAX_REDIRECT_HOPS, + }, + self.policy, + &operation.cancel, + Instant::now() + FETCH_DEADLINE, + ) + .await + .map_err(|error| error.message())?; + // Ownership moves into the blocking worker before any await. The worker + // always drops both archive and snapshot on failure or cancellation. + struct Archive(PathBuf); + impl Drop for Archive { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + let archive = Archive(artifact.path); + let session = Session { + root: self.temporary_root.clone(), + id: format!("lithe-plugin-disk-{}", install::uuid_like_suffix()), + }; + let coordinator = self.clone(); + let worker_operation = operation.clone(); + let ready = tauri::async_runtime::spawn_blocking(move || { + coordinator.check(&worker_operation)?; + let prepared = install::prepare_marketplace_install_snapshot( + &coordinator.install_root, + &session.root, + &session.id, + &archive.0, + &source, + )?; + coordinator.check(&worker_operation)?; + let requires_confirmation = prepared.approval_required; + Ok::<_, String>(ReadyInstall { + session, + source, + prepared, + requires_confirmation, + index_epoch, + }) + }) + .await + .map_err(|error| error.to_string())??; + let response = MarketplaceReview { + operation_id: id.into(), + source_id: ready.source.source_id.clone(), + source_name: ready.source.source_name.clone(), + index_url: ready.source.index_url.clone(), + official_publisher_verified: ready.source.official, + requires_confirmation: ready.requires_confirmation, + prepared: ready.prepared.clone(), + }; + let mut slot = operation + .ready + .lock() + .map_err(|_| "marketplace review poisoned")?; + self.check(&operation)?; + *slot = Some(ready); + Ok(response) + } + + #[cfg(test)] + pub fn pending_plugin(&self, window: &str, id: &str) -> Result { + self.operations + .lock() + .map_err(|_| "marketplace operations poisoned")? + .active + .get(&(window.into(), id.into())) + .and_then(|operation| operation.plugin.clone()) + .ok_or_else(|| "this window owns no such marketplace review".into()) + } + + pub fn claim_confirmation( + self: &Arc, + window: &str, + id: &str, + ) -> Result { + let operation = self + .operations + .lock() + .map_err(|_| "marketplace operations poisoned")? + .active + .get(&(window.into(), id.into())) + .cloned() + .ok_or("this window owns no such marketplace review")?; + let ready = operation + .ready + .lock() + .map_err(|_| "marketplace review poisoned")?; + if ready.is_none() { + return Err("the marketplace review is not ready".into()); + } + if operation.confirming.swap(true, Ordering::SeqCst) { + return Err("this marketplace review is already being confirmed".into()); + } + drop(ready); + let plugin_id = operation + .plugin + .clone() + .ok_or("not an installation review")?; + Ok(Confirmation { + coordinator: self.clone(), + operation, + id: id.into(), + plugin_id, + }) + } + + /// Test entry to the same transaction. Production first claims the review, + /// then holds M1's native lifecycle barrier before calling publish. + #[cfg(test)] + pub fn confirm( + self: &Arc, + window: &str, + id: &str, + digest: &str, + approved: bool, + ) -> Result { + let confirmation = self.claim_confirmation(window, id)?; + confirmation.check_review(digest, approved)?; + confirmation.publish(digest, approved) + } + + fn confirm_claimed( + &self, + operation: &Operation, + digest: &str, + approved: bool, + ) -> Result { + let ready = operation + .ready + .lock() + .map_err(|_| "marketplace review poisoned")? + .take() + .ok_or("the marketplace review is not ready or is already being confirmed")?; + self.check(operation)?; + if ready.requires_confirmation && !approved { + return Err( + "explicit approval is required for the permissions and new programs".into(), + ); + } + self.host.ensure_installable(&ready.source.plugin_id)?; + let candidate = install::confirm_marketplace_install_snapshot( + &self.install_root, + &ready.session.root, + &ready.session.id, + digest, + &ready.source, + )?; + install::publish_installed_version_guarded( + &self.install_root, + &candidate.plugin_id, + &candidate.version, + &ready.session.snapshot()?, + candidate.record.clone(), + &|activate| { + // Staging already passed M1's byte and trust checks. Only + // activation/record replacement holds these commit gates. + let epochs = self + .index_epochs + .lock() + .map_err(|_| "marketplace epochs poisoned")?; + let epoch = epochs + .get(&ready.source.source_id) + .ok_or("the selected index expired")?; + if epoch.value != ready.index_epoch || epoch.refreshing != 0 { + return Err("the source index changed; prepare a new marketplace review".into()); + } + self.host.with_source(&operation.source, &mut || { + operation + .cancel + .commit(Instant::now() + FETCH_DEADLINE, &mut *activate) + .map_err(|error| error.message())? + }) + }, + )?; + Ok(candidate) + } +} + +pub(crate) fn official_source(source: &MarketplaceSource) -> bool { + source.id == OFFICIAL_SOURCE_ID && source.index_url == OFFICIAL_INDEX_URL +} diff --git a/windows/tauri/src-tauri/src/plugin_host/marketplace_download.rs b/windows/tauri/src-tauri/src/plugin_host/marketplace_download.rs new file mode 100644 index 0000000000..9428748341 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/marketplace_download.rs @@ -0,0 +1,1421 @@ +//! Controlled downloads for declarative marketplace sources. +//! +//! The engine performs every HTTP round trip through an injected +//! [`HttpTransport`], so tests drive the real redirect, budget, deadline, and +//! cancellation logic against a local server while production keeps its +//! HTTPS-only policy. Redirects are resolved manually — a response never +//! silently drags the client to another origin, every hop is re-checked +//! against the fetch policy, and the hop count is bounded. A downloaded +//! artifact is written to a private `create_new` file under the caller's +//! runtime download root only after its SHA-256 matches the digest the +//! Core-validated index published: a completed download is a byte-exact +//! archive, never verified package content. + +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; +use tokio::sync::Notify; + +/// A cooperative cancellation handle shared with a download's owner. Once +/// cancelled, network waits wake immediately. Cancellation and final file/cache +/// publication share a lock: cancellation before that commit prevents publication; +/// once publication starts, its result belongs to the caller and is not revoked. +#[derive(Clone, Default)] +pub struct CancelToken(Arc); + +#[derive(Default)] +struct CancelState { + cancelled: AtomicBool, + notify: Notify, + publication: Mutex<()>, +} + +impl CancelToken { + pub fn new() -> Self { + Self::default() + } + + pub fn cancel(&self) { + let _publication = self + .0 + .publication + .lock() + .unwrap_or_else(|error| error.into_inner()); + self.0.cancelled.store(true, Ordering::SeqCst); + self.0.notify.notify_waiters(); + } + + pub fn is_cancelled(&self) -> bool { + self.0.cancelled.load(Ordering::SeqCst) + } + + async fn cancelled(&self) { + // Register before checking the flag, including when the future has not + // yet been polled by select. No cancellation can fall between the two. + let notified = self.0.notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if !self.is_cancelled() { + notified.await; + } + } + + pub(super) fn check(&self, deadline: Instant) -> Result<(), FetchFailure> { + if self.is_cancelled() { + Err(FetchFailure::Cancelled) + } else if Instant::now() >= deadline { + Err(FetchFailure::DeadlineExceeded) + } else { + Ok(()) + } + } + + pub(super) fn commit( + &self, + deadline: Instant, + publish: impl FnOnce() -> T, + ) -> Result { + let _publication = self + .0 + .publication + .lock() + .unwrap_or_else(|error| error.into_inner()); + self.check(deadline)?; + Ok(publish()) + } +} + +/// Competes every network wait with cancellation and the same absolute deadline. +async fn wait_for( + cancel: &CancelToken, + deadline: Instant, + operation: impl Future>, +) -> Result { + cancel.check(deadline)?; + tokio::select! { + biased; + _ = cancel.cancelled() => Err(FetchFailure::Cancelled), + _ = tokio::time::sleep_until(deadline.into()) => Err(FetchFailure::DeadlineExceeded), + result = operation => { + cancel.check(deadline)?; + result + } + } +} + +/// Owns a private runtime file until its atomic rename succeeds. Callers supply +/// only their writable state/download root; this never targets packaged resources. +/// The file handle closes before cleanup, including write/flush/rename failures. +pub(super) struct PendingFile { + path: PathBuf, + published: bool, +} + +impl PendingFile { + pub(super) fn write(path: PathBuf, bytes: &[u8]) -> std::io::Result { + let file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path)?; + let pending = Self { + path, + published: false, + }; + let result = (move || { + use std::io::Write; + let mut file = file; + file.write_all(bytes)?; + file.flush() + })(); + result?; + Ok(pending) + } + + pub(super) fn publish(self, target: &Path) -> std::io::Result<()> { + self.publish_with(target, |source, target| std::fs::rename(source, target)) + } + + pub(super) fn publish_with( + mut self, + target: &Path, + rename: impl FnOnce(&Path, &Path) -> std::io::Result<()>, + ) -> std::io::Result<()> { + rename(&self.path, target)?; + self.published = true; + Ok(()) + } +} + +impl Drop for PendingFile { + fn drop(&mut self) { + if !self.published { + let _ = std::fs::remove_file(&self.path); + } + } +} + +/// Upper bounds for one controlled fetch. Production call sites use +/// [`MAX_INDEX_JSON_BYTES`] for index documents and [`MAX_ARTIFACT_BYTES`] +/// for plugin archives; tests pass smaller budgets to exercise the boundary +/// without moving real payloads. +#[derive(Debug, Clone, Copy)] +pub struct FetchLimits { + /// Maximum accepted response body across one target, redirects included. + pub max_bytes: u64, + /// Maximum number of redirects followed for one target. + pub max_redirects: usize, +} + +/// Index documents beyond this size are refused instead of parsed. +pub const MAX_INDEX_JSON_BYTES: u64 = 8 * 1024 * 1024; +/// Plugin archives beyond this size are refused; the same budget the disk +/// install path enforces on decompressed content. +pub const MAX_ARTIFACT_BYTES: u64 = 128 * 1024 * 1024; +/// Redirect hops beyond this count are refused. +pub const MAX_REDIRECT_HOPS: usize = 5; + +/// Scheme policy for one fetch. Production fetches are HTTPS-only; the +/// loopback constructor is compiled for tests only so the real engine can be +/// exercised against a local HTTP server without ever weakening the shipped +/// policy. +#[derive(Debug, Clone, Copy)] +pub struct FetchPolicy { + loopback_http_allowed: bool, +} + +impl FetchPolicy { + /// The production policy: every request target and redirect hop must be + /// `https`. + pub fn https_only() -> Self { + Self { + loopback_http_allowed: false, + } + } + + #[cfg(test)] + pub fn loopback_http_for_tests() -> Self { + Self { + loopback_http_allowed: true, + } + } + + fn allows(&self, url: &url::Url) -> bool { + match url.scheme() { + "https" => true, + "http" if self.loopback_http_allowed => matches!( + url.host_str(), + Some("127.0.0.1") | Some("::1") | Some("localhost") + ), + _ => false, + } + } +} + +/// Every failure a controlled fetch can produce, with a stable kind the +/// marketplace surface can branch on without parsing messages. +#[derive(Debug, Clone, PartialEq)] +pub enum FetchFailure { + Cancelled, + DeadlineExceeded, + /// A request or redirect target was refused by the fetch policy. + DisallowedTarget { + url: String, + }, + /// A redirect response carried no usable `Location`. + RedirectWithoutLocation { + status: u16, + }, + TooManyRedirects { + hops: usize, + }, + BudgetExceeded { + limit: u64, + }, + /// The final response was not a success status. + HttpError { + status: u16, + url: String, + }, + /// The transport failed (connection error, truncated body, ...). + Transport(String), + /// The downloaded bytes do not match the published digest. + DigestMismatch { + expected: String, + }, +} + +impl FetchFailure { + /// Stable failure kind for the marketplace surface. + pub fn kind(&self) -> &'static str { + match self { + FetchFailure::Cancelled => "cancelled", + FetchFailure::DeadlineExceeded => "deadline", + FetchFailure::DisallowedTarget { .. } => "disallowedTarget", + FetchFailure::RedirectWithoutLocation { .. } => "redirectWithoutLocation", + FetchFailure::TooManyRedirects { .. } => "tooManyRedirects", + FetchFailure::BudgetExceeded { .. } => "budgetExceeded", + FetchFailure::HttpError { .. } => "httpError", + FetchFailure::Transport(_) => "transport", + FetchFailure::DigestMismatch { .. } => "digestMismatch", + } + } + + pub fn message(&self) -> String { + match self { + FetchFailure::Cancelled => "the fetch was cancelled".to_string(), + FetchFailure::DeadlineExceeded => "the fetch exceeded its deadline".to_string(), + FetchFailure::DisallowedTarget { url } => { + format!("the fetch target {url} is not allowed by the source policy") + } + FetchFailure::RedirectWithoutLocation { status } => { + format!("the redirect response {status} carries no Location header") + } + FetchFailure::TooManyRedirects { hops } => { + format!("the fetch exceeded {hops} redirects") + } + FetchFailure::BudgetExceeded { limit } => { + format!("the response exceeds the {limit} byte budget") + } + FetchFailure::HttpError { status, url } => { + format!("the fetch of {url} returned HTTP {status}") + } + FetchFailure::Transport(error) => format!("the fetch failed: {error}"), + FetchFailure::DigestMismatch { expected } => { + format!("the downloaded archive does not match the published digest {expected}") + } + } + } +} + +/// One HTTP response as the engine sees it: status, lowercased headers, and +/// the body capped at the transport's budget. +#[derive(Debug, Clone)] +pub struct HttpResponse { + pub status: u16, + pub headers: HashMap, + pub body: Vec, +} + +impl HttpResponse { + fn header(&self, name: &str) -> Option<&str> { + self.headers + .get(&name.to_ascii_lowercase()) + .map(String::as_str) + } +} + +/// Performs one HTTP GET. Implementations enforce the body budget while the +/// response streams (a body that grows past `max_bytes` fails with +/// [`FetchFailure::BudgetExceeded`]) and respect the deadline and cancel +/// token during every network wait. Redirect handling stays with the engine, so a +/// 3xx response's body is buffered but ignored. +pub trait HttpTransport: Send + Sync { + fn get<'a>( + &'a self, + url: &'a str, + max_bytes: u64, + deadline: Instant, + cancel: &'a CancelToken, + ) -> Pin> + Send + 'a>>; +} + +/// The production transport: the shared `tauri_plugin_http` reqwest client +/// with redirects disabled (the engine resolves every hop itself), a per +/// request timeout bounded by the remaining deadline, and streaming chunk +/// reads that stop at the budget. +#[derive(Clone)] +pub struct ReqwestTransport { + client: tauri_plugin_http::reqwest::Client, + #[cfg(test)] + on_chunk: Option>, +} + +impl ReqwestTransport { + /// Builds the shared client. Redirects are never followed automatically: + /// every hop must pass the fetch policy first. + pub fn new() -> Result { + let client = tauri_plugin_http::reqwest::Client::builder() + .redirect(tauri_plugin_http::reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("the marketplace HTTP client could not be built: {error}"))?; + Ok(Self { + client, + #[cfg(test)] + on_chunk: None, + }) + } +} + +impl Default for ReqwestTransport { + fn default() -> Self { + Self::new().expect("the marketplace HTTP client builds") + } +} + +impl HttpTransport for ReqwestTransport { + fn get<'a>( + &'a self, + url: &'a str, + max_bytes: u64, + deadline: Instant, + cancel: &'a CancelToken, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(FetchFailure::DeadlineExceeded); + } + let response = wait_for(cancel, deadline, async { + self.client + .get(url) + .timeout(remaining) + .send() + .await + .map_err(transport_error) + }) + .await?; + let status = response.status().as_u16(); + let headers = response + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_ascii_lowercase(), + value.to_str().unwrap_or_default().to_string(), + ) + }) + .collect::>(); + let mut body = Vec::new(); + let mut response = response; + while let Some(chunk) = wait_for(cancel, deadline, async { + response.chunk().await.map_err(transport_error) + }) + .await? + { + if body.len() as u64 + chunk.len() as u64 > max_bytes { + return Err(FetchFailure::BudgetExceeded { limit: max_bytes }); + } + body.extend_from_slice(&chunk); + #[cfg(test)] + if let Some(on_chunk) = &self.on_chunk { + on_chunk(body.len()); + } + } + Ok(HttpResponse { + status, + headers, + body, + }) + }) + } +} + +/// One completed controlled fetch: the body, the URL it was finally served +/// from, and how many redirect hops were followed. +#[derive(Debug, Clone)] +pub struct FetchOutcome { + pub body: Vec, + pub final_url: String, + pub hops: usize, +} + +/// Follows at most `limits.max_redirects` hops from `initial_url`, checking +/// every target (the initial URL included) against the fetch policy and the +/// embedded-credential rule, the deadline, and the cancel token. The body is +/// capped at `limits.max_bytes`. +pub async fn fetch_controlled( + transport: &dyn HttpTransport, + initial_url: &str, + limits: FetchLimits, + policy: FetchPolicy, + cancel: &CancelToken, + deadline: Instant, +) -> Result { + let mut url = initial_url.to_string(); + let mut hops = 0usize; + loop { + cancel.check(deadline)?; + check_target(&url, policy)?; + let response = wait_for( + cancel, + deadline, + transport.get(&url, limits.max_bytes, deadline, cancel), + ) + .await?; + if (300..400).contains(&response.status) { + if hops >= limits.max_redirects { + return Err(FetchFailure::TooManyRedirects { hops }); + } + let location = + response + .header("location") + .ok_or(FetchFailure::RedirectWithoutLocation { + status: response.status, + })?; + let target = resolved_target(&url, location)?; + check_target(&target, policy)?; + url = target; + hops += 1; + continue; + } + if !(200..300).contains(&response.status) { + return Err(FetchFailure::HttpError { + status: response.status, + url, + }); + } + return Ok(FetchOutcome { + body: response.body, + final_url: url, + hops, + }); + } +} + +/// A downloaded, digest-bound artifact waiting for the install flow. +#[derive(Debug, Clone, PartialEq)] +pub struct DownloadedArtifact { + /// Private file under the download root; consumed by the install + /// transaction or removed with it. + pub path: PathBuf, + /// Lowercase hex SHA-256 of the file bytes. + pub sha256: String, + pub final_url: String, + pub bytes: u64, +} + +/// Downloads one artifact and binds it to `expected_sha256` +/// (case-insensitive hex) before writing it to a private `create_new` file +/// under `download_root`. A digest mismatch, any failure, or cancellation +/// leaves no file behind; the returned archive is byte-exact published +/// content, not verified package content. +pub async fn download_artifact( + download_root: &Path, + transport: &dyn HttpTransport, + url: &str, + expected_sha256: &str, + limits: FetchLimits, + policy: FetchPolicy, + cancel: &CancelToken, + deadline: Instant, +) -> Result { + let outcome = fetch_controlled(transport, url, limits, policy, cancel, deadline).await?; + cancel.check(deadline)?; + let digest = format!("{:x}", Sha256::digest(&outcome.body)); + cancel.check(deadline)?; + if !digest.eq_ignore_ascii_case(expected_sha256) { + return Err(FetchFailure::DigestMismatch { + expected: expected_sha256.to_string(), + }); + } + std::fs::create_dir_all(download_root).map_err(|error| { + FetchFailure::Transport(format!("the download root is unusable: {error}")) + })?; + let path = download_root.join(format!( + "marketplace-download-{}-{}.zip", + std::process::id(), + unique_suffix() + )); + let pending = PendingFile::write(path.with_extension("zip.tmp"), &outcome.body) + .map_err(|error| FetchFailure::Transport(format!("the download file failed: {error}")))?; + // The token's commit lock is held only for publication, never for hashing, + // writing or flushing. A losing cancellation drops the pending-file owner. + cancel + .commit(deadline, || pending.publish(&path))? + .map_err(|error| { + FetchFailure::Transport(format!("the download publish failed: {error}")) + })?; + Ok(DownloadedArtifact { + path, + sha256: digest, + final_url: outcome.final_url, + bytes: outcome.body.len() as u64, + }) +} + +/// Checks one request target against the fetch policy and the +/// embedded-credential rule, mirroring the rule the AI commit client applies +/// to redirect targets. +fn check_target(url: &str, policy: FetchPolicy) -> Result<(), FetchFailure> { + let target = url::Url::parse(url).map_err(|error| FetchFailure::DisallowedTarget { + url: format!("{url} ({error})"), + })?; + if !policy.allows(&target) || !target.username().is_empty() || target.password().is_some() { + return Err(FetchFailure::DisallowedTarget { + url: target.to_string(), + }); + } + Ok(()) +} + +/// Resolves a `Location` header against the current target. +fn resolved_target(current: &str, location: &str) -> Result { + let base = url::Url::parse(current).map_err(|error| FetchFailure::DisallowedTarget { + url: format!("{current} ({error})"), + })?; + let target = base + .join(location) + .map_err(|error| FetchFailure::DisallowedTarget { + url: format!("{location} ({error})"), + })?; + Ok(target.to_string()) +} + +/// Flattens a reqwest error with its whole source chain: the top message +/// alone ("error sending request") hides whether the connection was refused, +/// reset, or proxied away. +fn transport_error(error: tauri_plugin_http::reqwest::Error) -> FetchFailure { + if error.is_timeout() { + return FetchFailure::DeadlineExceeded; + } + let mut message = error.to_string(); + let mut cause = std::error::Error::source(&error); + while let Some(error) = cause { + message.push_str(&format!(": {error}")); + cause = error.source(); + } + FetchFailure::Transport(message) +} + +/// Time- and process-derived uniqueness for private file names. +fn unique_suffix() -> String { + static SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + format!( + "{}-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(), + SEQUENCE.fetch_add(1, Ordering::SeqCst) + ) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use std::io::{Read, Write}; + use std::time::Duration; + + /// One scripted response of the local test server. `content_length` + /// overrides the advertised Content-Length and `body_prefix` limits how + /// many bytes are actually written, which is how the tests model a + /// truncated download. + #[derive(Clone)] + pub(crate) struct TestResponse { + pub status: u16, + pub headers: Vec<(String, String)>, + pub body: Vec, + pub content_length: Option, + pub body_prefix: Option, + pub pause: Option<(Arc, PauseAt)>, + } + + impl TestResponse { + pub fn ok(body: Vec) -> Self { + Self { + status: 200, + headers: Vec::new(), + body, + content_length: None, + body_prefix: None, + pause: None, + } + } + + pub fn redirect(location: &str) -> Self { + Self { + status: 302, + headers: vec![("Location".to_string(), location.to_string())], + body: Vec::new(), + content_length: None, + body_prefix: None, + pause: None, + } + } + + pub fn paused(body: Vec, phase: PauseAt) -> (Self, Arc) { + let gate = TestGate::new(); + let mut response = Self::ok(body); + response.pause = Some((gate.clone(), phase)); + (response, gate) + } + + pub fn with_status(status: u16) -> Self { + Self { + status, + ..Self::ok(Vec::new()) + } + } + } + + /// A gated response can stop before headers or after its final body bytes. + /// The server owner releases every gate during teardown, including panic paths. + #[derive(Clone, Copy)] + pub(crate) enum PauseAt { + Headers, + Eof, + } + + pub(crate) struct TestGate { + entered: Notify, + released: Mutex, + wake: std::sync::Condvar, + } + + impl TestGate { + fn new() -> Arc { + Arc::new(Self { + entered: Notify::new(), + released: Mutex::new(false), + wake: std::sync::Condvar::new(), + }) + } + + pub async fn entered(&self) { + tokio::time::timeout(Duration::from_secs(2), self.entered.notified()) + .await + .expect("server reaches the controlled boundary"); + } + + fn wait(&self, deadline: Instant) { + self.entered.notify_one(); + let released = self + .released + .lock() + .unwrap_or_else(|error| error.into_inner()); + let _ = self + .wake + .wait_timeout_while( + released, + deadline.saturating_duration_since(Instant::now()), + |released| !*released, + ) + .expect("gate wait"); + } + + pub fn release(&self) { + *self + .released + .lock() + .unwrap_or_else(|error| error.into_inner()) = true; + self.wake.notify_all(); + } + } + + /// One blocking owner serves connections serially; there are no detached + /// connection threads or global locks. Drop releases gates, shuts down the + /// active socket and wakes accept, then joins only after a bounded done signal. + pub(crate) struct TestHttpServer { + pub base_url: String, + address: std::net::SocketAddr, + shutdown: Arc, + active: Arc>>, + accepted: Arc, + gates: Vec>, + done: std::sync::mpsc::Receiver<()>, + thread: Option>, + } + + impl TestHttpServer { + pub fn start(routes: Vec<(&'static str, Vec)>) -> Self { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("test listener binds"); + let address = listener.local_addr().expect("addr"); + let base_url = format!("http://{address}"); + let shutdown = Arc::new(AtomicBool::new(false)); + let active = Arc::new(Mutex::new(None)); + let accepted = Arc::new(Notify::new()); + let thread_accepted = accepted.clone(); + let gates = routes + .iter() + .flat_map(|(_, responses)| responses.iter()) + .filter_map(|response| response.pause.as_ref().map(|(gate, _)| gate.clone())) + .collect(); + let thread_shutdown = shutdown.clone(); + let thread_active = active.clone(); + let (done_tx, done) = std::sync::mpsc::channel(); + let thread = std::thread::spawn(move || { + // Signal even if serving panics, so the owner can join the failure. + struct Done(std::sync::mpsc::Sender<()>); + impl Drop for Done { + fn drop(&mut self) { + let _ = self.0.send(()); + } + } + let _done = Done(done_tx); + let mut routes = routes; + while !thread_shutdown.load(Ordering::SeqCst) { + let Ok((stream, _)) = listener.accept() else { + break; + }; + *thread_active.lock().expect("active socket") = + Some(stream.try_clone().expect("socket clone")); + thread_accepted.notify_one(); + if !thread_shutdown.load(Ordering::SeqCst) { + serve_connection(stream, &mut routes, &thread_shutdown); + } + thread_active.lock().expect("active socket").take(); + } + }); + Self { + base_url, + address, + shutdown, + active, + accepted, + gates, + done, + thread: Some(thread), + } + } + } + + impl Drop for TestHttpServer { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::SeqCst); + for gate in &self.gates { + gate.release(); + } + if let Some(stream) = self + .active + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take() + { + let _ = stream.shutdown(std::net::Shutdown::Both); + } + let _ = std::net::TcpStream::connect_timeout(&self.address, Duration::from_millis(200)); + if self.done.recv_timeout(Duration::from_secs(2)).is_err() { + // A native thread cannot be killed safely. The timing harness owns + // this test process: fail it instead of hanging or detaching work. + eprintln!("marketplace test server failed to stop within its cleanup deadline"); + std::process::abort(); + } + if let Some(thread) = self.thread.take() { + let result = thread.join(); + if !std::thread::panicking() { + result.expect("server thread joins"); + } + } + } + } + + fn write_bounded( + stream: &mut std::net::TcpStream, + mut bytes: &[u8], + stop: &AtomicBool, + deadline: Instant, + ) -> std::io::Result<()> { + while !bytes.is_empty() { + if stop.load(Ordering::SeqCst) || Instant::now() >= deadline { + return Err(std::io::ErrorKind::TimedOut.into()); + } + stream.set_write_timeout(Some(io_wait_bound(deadline)))?; + let written = match stream.write(bytes) { + Ok(written) => written, + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + continue + } + Err(error) => return Err(error), + }; + if written == 0 { + return Err(std::io::ErrorKind::WriteZero.into()); + } + bytes = &bytes[written..]; + } + Ok(()) + } + + fn io_wait_bound(deadline: Instant) -> Duration { + // Windows may leave a synchronous read pending after another socket + // handle calls shutdown. Bound that OS wait too, so the owner stop flag + // is observed without relying on interrupting the pending syscall. + deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_millis(100)) + } + + fn serve_connection( + mut stream: std::net::TcpStream, + routes: &mut [(&'static str, Vec)], + stop: &AtomicBool, + ) { + let deadline = Instant::now() + Duration::from_secs(2); + let mut buffer = Vec::new(); + let mut chunk = [0u8; 1024]; + while !stop.load(Ordering::SeqCst) && Instant::now() < deadline { + if stream + .set_read_timeout(Some(io_wait_bound(deadline))) + .is_err() + { + return; + } + match stream.read(&mut chunk) { + Ok(0) => return, + Ok(read) => { + buffer.extend_from_slice(&chunk[..read]); + if buffer.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + if buffer.len() > 16 * 1024 { + return; + } + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + continue + } + Err(_) => return, + } + } + let request = String::from_utf8_lossy(&buffer); + let path = request.split_whitespace().nth(1).unwrap_or_default(); + let response = routes + .iter_mut() + .find(|(route, _)| path == *route || path.starts_with(&format!("{route}?"))) + .map(|(_, responses)| { + if responses.len() > 1 { + responses.remove(0) + } else { + responses[0].clone() + } + }) + .unwrap_or_else(|| TestResponse::with_status(404)); + if let Some((gate, PauseAt::Headers)) = &response.pause { + gate.wait(deadline); + } + // A close-delimited body lets tests park after the final chunk but before + // EOF, without lying about Content-Length or relying on socket timing. + let length = if matches!(&response.pause, Some((_, PauseAt::Eof))) { + String::new() + } else { + format!( + "Content-Length: {}\r\n", + response.content_length.unwrap_or(response.body.len()) + ) + }; + let mut head = format!( + "HTTP/1.1 {} OK\r\n{length}Connection: close\r\n", + response.status + ); + for (name, value) in &response.headers { + head.push_str(&format!("{name}: {value}\r\n")); + } + head.push_str("\r\n"); + let written = response + .body_prefix + .unwrap_or(response.body.len()) + .min(response.body.len()); + if write_bounded(&mut stream, head.as_bytes(), stop, deadline).is_err() { + return; + } + if write_bounded(&mut stream, &response.body[..written], stop, deadline).is_err() { + return; + } + if let Some((gate, PauseAt::Eof)) = &response.pause { + gate.wait(deadline); + } + } + + /// Every test owns its private directory even when an assertion unwinds. + pub(crate) struct TestRoot(pub PathBuf); + + impl TestRoot { + pub fn new(label: &str) -> Self { + let root = std::env::temp_dir().join(format!( + "lithe-marketplace-{label}-{}-{}", + std::process::id(), + unique_suffix() + )); + std::fs::create_dir_all(&root).expect("test runtime root"); + Self(root) + } + } + + impl Drop for TestRoot { + fn drop(&mut self) { + let result = std::fs::remove_dir_all(&self.0); + if !std::thread::panicking() { + result.expect("test runtime root is removed"); + } + } + } + + #[tokio::test] + async fn cancellation_wakes_registered_waiters_and_remains_sticky() { + let cancel = CancelToken::new(); + let first = cancel.cancelled(); + let second = cancel.cancelled(); + tokio::pin!(first, second); + tokio::select! { + biased; + _ = &mut first => panic!("not cancelled yet"), + _ = &mut second => panic!("not cancelled yet"), + _ = std::future::ready(()) => {} + } + cancel.cancel(); + tokio::time::timeout(Duration::from_secs(2), async { + tokio::join!(&mut first, &mut second); + cancel.cancelled().await; + }) + .await + .expect("all waiters wake, including a later registration"); + } + + #[tokio::test] + async fn cancellation_interrupts_an_in_flight_request_before_headers() { + let (response, gate) = TestResponse::paused(b"archive".to_vec(), PauseAt::Headers); + let server = TestHttpServer::start(vec![("/pending", vec![response])]); + let transport = ReqwestTransport::new().expect("transport"); + let cancel = CancelToken::new(); + let url = format!("{}/pending", server.base_url); + let fetch = fetch_controlled( + &transport, + &url, + test_limits(1024), + FetchPolicy::loopback_http_for_tests(), + &cancel, + test_deadline(), + ); + tokio::pin!(fetch); + tokio::select! { + result = &mut fetch => panic!("request must still be waiting: {result:?}"), + _ = gate.entered() => {} + } + cancel.cancel(); + let result = tokio::time::timeout(Duration::from_secs(1), &mut fetch) + .await + .expect("cancel wakes send before the network deadline"); + assert_eq!( + result.expect_err("request cancelled"), + FetchFailure::Cancelled + ); + // The gate remains closed until the owner's Drop. Cancellation must not + // require the server to send headers or the request deadline to expire. + } + + #[tokio::test] + async fn cancelling_after_the_final_chunk_before_eof_never_publishes_an_artifact() { + let root = TestRoot::new("cancel-eof"); + let (response, _gate) = TestResponse::paused(b"archive".to_vec(), PauseAt::Eof); + let server = TestHttpServer::start(vec![("/pending", vec![response])]); + let consumed = Arc::new(Notify::new()); + let mut transport = ReqwestTransport::new().expect("transport"); + let notify = consumed.clone(); + transport.on_chunk = Some(Arc::new(move |bytes| { + if bytes == b"archive".len() { + notify.notify_one(); + } + })); + let cancel = CancelToken::new(); + let url = format!("{}/pending", server.base_url); + let digest = format!("{:x}", Sha256::digest(b"archive")); + let download = download_artifact( + &root.0, + &transport, + &url, + &digest, + test_limits(1024), + FetchPolicy::loopback_http_for_tests(), + &cancel, + test_deadline(), + ); + tokio::pin!(download); + tokio::select! { + result = &mut download => panic!("EOF is still gated: {result:?}"), + result = tokio::time::timeout(Duration::from_secs(1), consumed.notified()) => { + result.expect("the real client consumes the last body chunk"); + } + } + // This signal is emitted by the real client after appending the last + // body chunk, so the next network wait is specifically for EOF. + cancel.cancel(); + let result = tokio::time::timeout(Duration::from_secs(1), &mut download) + .await + .expect("cancel wakes the EOF wait"); + assert_eq!( + result.expect_err("download cancelled"), + FetchFailure::Cancelled + ); + assert_eq!(std::fs::read_dir(&root.0).expect("root").count(), 0); + } + + #[test] + fn cancellation_before_publication_removes_a_fully_written_private_file() { + let root = TestRoot::new("cancel-publication"); + let target = root.0.join("artifact.zip"); + let pending = + PendingFile::write(root.0.join("artifact.tmp"), b"flushed bytes").expect("stage"); + let cancel = CancelToken::new(); + cancel.cancel(); + let result = cancel.commit(test_deadline(), || pending.publish(&target)); + assert_eq!( + result.expect_err("cancel wins commit"), + FetchFailure::Cancelled + ); + assert_eq!(std::fs::read_dir(&root.0).expect("root").count(), 0); + } + + #[tokio::test] + async fn server_owner_interrupts_a_connection_still_reading_request_headers() { + let server = TestHttpServer::start(vec![]); + let mut connection = std::net::TcpStream::connect(server.address).expect("connect"); + tokio::time::timeout(Duration::from_secs(1), server.accepted.notified()) + .await + .expect("connection is owned before shutdown"); + drop(server); + assert_connection_closed(&mut connection); + } + + #[tokio::test] + async fn unwinding_releases_response_gates_threads_and_private_roots() { + let root = TestRoot::new("unwind"); + let path = root.0.clone(); + let (response, gate) = TestResponse::paused(b"archive".to_vec(), PauseAt::Headers); + let server = TestHttpServer::start(vec![("/", vec![response])]); + let mut connection = std::net::TcpStream::connect(server.address).expect("connect"); + connection + .set_write_timeout(Some(Duration::from_secs(1))) + .expect("bounded write"); + connection + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .expect("request"); + gate.entered().await; + let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + let _root = root; + let _server = server; + panic!("simulated assertion failure"); + })); + assert!(unwind.is_err()); + assert!( + !path.exists(), + "RAII removes the root even during an assertion unwind" + ); + assert_connection_closed(&mut connection); + } + + fn assert_connection_closed(connection: &mut std::net::TcpStream) { + use std::io::ErrorKind; + connection + .set_read_timeout(Some(Duration::from_secs(1))) + .expect("bounded read"); + match connection.read(&mut [0]) { + Ok(0) => {} + Err(error) + if matches!( + error.kind(), + ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::BrokenPipe + | ErrorKind::UnexpectedEof + ) => {} + result => panic!("owner must close the socket, not merely time out: {result:?}"), + } + } + + fn test_limits(max_bytes: u64) -> FetchLimits { + FetchLimits { + max_bytes, + max_redirects: MAX_REDIRECT_HOPS, + } + } + + fn test_deadline() -> Instant { + Instant::now() + Duration::from_secs(10) + } + + #[tokio::test] + async fn fetch_serves_the_body_and_reports_the_final_url() { + let server = TestHttpServer::start(vec![( + "/index.json", + vec![TestResponse::ok(br#"{"ok": true}"#.to_vec())], + )]); + let transport = ReqwestTransport::new().expect("transport"); + let outcome = fetch_controlled( + &transport, + &format!("{}/index.json", server.base_url), + test_limits(1024), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect("the fetch succeeds"); + assert_eq!(outcome.body, br#"{"ok": true}"#.to_vec()); + assert_eq!(outcome.hops, 0); + assert_eq!(outcome.final_url, format!("{}/index.json", server.base_url)); + } + + #[tokio::test] + async fn redirects_are_followed_within_the_policy_and_reported() { + let server = TestHttpServer::start(vec![ + ("/start", vec![TestResponse::redirect("/final")]), + ("/final", vec![TestResponse::ok(b"final body".to_vec())]), + ]); + let transport = ReqwestTransport::new().expect("transport"); + let outcome = fetch_controlled( + &transport, + &format!("{}/start", server.base_url), + test_limits(1024), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect("the redirect chain succeeds"); + assert_eq!(outcome.body, b"final body".to_vec()); + assert_eq!(outcome.hops, 1); + assert_eq!(outcome.final_url, format!("{}/final", server.base_url)); + } + + #[tokio::test] + async fn insecure_and_credential_targets_are_refused() { + // The production policy refuses every non-HTTPS target outright. + let error = fetch_controlled( + &ReqwestTransport::new().expect("transport"), + "http://127.0.0.1:9/none", + test_limits(1024), + FetchPolicy::https_only(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect_err("plain http is refused in production"); + assert_eq!(error.kind(), "disallowedTarget"); + + let server = TestHttpServer::start(vec![ + ( + "/to-insecure", + vec![TestResponse::redirect("http://evil.example.com/x")], + ), + ( + "/to-credentials", + vec![TestResponse::redirect("https://user:pass@example.com/x")], + ), + ]); + let transport = ReqwestTransport::new().expect("transport"); + for path in ["/to-insecure", "/to-credentials"] { + let error = fetch_controlled( + &transport, + &format!("{}{path}", server.base_url), + test_limits(1024), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect_err("the disallowed redirect target must be refused"); + assert_eq!(error.kind(), "disallowedTarget", "{error:?}"); + } + } + + #[tokio::test] + async fn too_many_redirects_is_bounded() { + let server = TestHttpServer::start(vec![("/loop", vec![TestResponse::redirect("/loop")])]); + let transport = ReqwestTransport::new().expect("transport"); + let error = fetch_controlled( + &transport, + &format!("{}/loop", server.base_url), + FetchLimits { + max_bytes: 1024, + max_redirects: 3, + }, + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect_err("the redirect loop must be bounded"); + assert_eq!(error, FetchFailure::TooManyRedirects { hops: 3 }); + } + + #[tokio::test] + async fn budget_exceeded_aborts_mid_body() { + let server = + TestHttpServer::start(vec![("/big", vec![TestResponse::ok(vec![b'x'; 10_000])])]); + let transport = ReqwestTransport::new().expect("transport"); + let error = fetch_controlled( + &transport, + &format!("{}/big", server.base_url), + test_limits(100), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect_err("the oversized body must abort"); + assert_eq!(error, FetchFailure::BudgetExceeded { limit: 100 }); + } + + #[tokio::test] + async fn truncated_body_is_a_transport_error() { + let server = TestHttpServer::start(vec![( + "/truncated", + vec![TestResponse { + status: 200, + headers: Vec::new(), + body: vec![b'y'; 1_000], + content_length: Some(1_000), + body_prefix: Some(100), + pause: None, + }], + )]); + let transport = ReqwestTransport::new().expect("transport"); + let error = fetch_controlled( + &transport, + &format!("{}/truncated", server.base_url), + test_limits(10_000), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect_err("the truncated body must be a transport error"); + assert_eq!(error.kind(), "transport", "{error:?}"); + } + + #[tokio::test] + async fn deadline_and_cancel_are_honored_before_any_request() { + let transport = ReqwestTransport::new().expect("transport"); + let error = fetch_controlled( + &transport, + "https://example.com/index.json", + test_limits(1024), + FetchPolicy::https_only(), + &CancelToken::new(), + Instant::now() - Duration::from_secs(1), + ) + .await + .expect_err("an expired deadline must fail immediately"); + assert_eq!(error, FetchFailure::DeadlineExceeded); + + let cancel = CancelToken::new(); + cancel.cancel(); + let error = fetch_controlled( + &transport, + "https://example.com/index.json", + test_limits(1024), + FetchPolicy::https_only(), + &cancel, + test_deadline(), + ) + .await + .expect_err("a cancelled fetch must fail immediately"); + assert_eq!(error, FetchFailure::Cancelled); + } + + #[tokio::test] + async fn http_error_status_is_reported() { + let server = + TestHttpServer::start(vec![("/missing", vec![TestResponse::with_status(404)])]); + let transport = ReqwestTransport::new().expect("transport"); + let error = fetch_controlled( + &transport, + &format!("{}/missing", server.base_url), + test_limits(1024), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect_err("a 404 must be reported"); + assert_eq!( + error, + FetchFailure::HttpError { + status: 404, + url: format!("{}/missing", server.base_url), + } + ); + } + + #[tokio::test] + async fn download_artifact_binds_the_digest_and_cleans_up_on_mismatch() { + let server = TestHttpServer::start(vec![( + "/plugin.zip", + vec![TestResponse::ok(b"archive bytes".to_vec())], + )]); + let transport = ReqwestTransport::new().expect("transport"); + let root = TestRoot::new("download-ok"); + let download_root = &root.0; + let digest = format!("{:x}", Sha256::digest(b"archive bytes")); + let artifact = download_artifact( + &download_root, + &transport, + &format!("{}/plugin.zip", server.base_url), + &digest, + test_limits(1024), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect("the digest-matched download succeeds"); + assert_eq!( + std::fs::read(&artifact.path).expect("artifact bytes"), + b"archive bytes".to_vec() + ); + assert_eq!(artifact.sha256, digest); + assert_eq!(artifact.bytes, 13); + assert!(artifact.path.starts_with(&download_root)); + + // A digest mismatch leaves the private root with only the matched + // download. + let error = download_artifact( + &download_root, + &transport, + &format!("{}/plugin.zip", server.base_url), + &"0".repeat(64), + test_limits(1024), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect_err("the digest mismatch must be refused"); + assert_eq!( + error, + FetchFailure::DigestMismatch { + expected: "0".repeat(64), + } + ); + let leftovers: Vec<_> = std::fs::read_dir(&download_root) + .expect("root readable") + .flatten() + .map(|entry| entry.file_name().to_string_lossy().to_string()) + .collect(); + assert_eq!( + leftovers.len(), + 1, + "only the matched download remains: {leftovers:?}" + ); + + // A second successful download never reuses a file name. + let second = download_artifact( + &download_root, + &transport, + &format!("{}/plugin.zip", server.base_url), + &digest, + test_limits(1024), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect("the second download succeeds"); + assert_ne!(second.path, artifact.path); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/marketplace_index.rs b/windows/tauri/src-tauri/src/plugin_host/marketplace_index.rs new file mode 100644 index 0000000000..77e9cc2f11 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/marketplace_index.rs @@ -0,0 +1,1175 @@ +//! Marketplace index refresh and the last-known-good cache. +//! +//! Index URLs and index legality are Core semantics: the caller hands in a +//! source identity whose `indexURL` already went through +//! `plugins.marketplace.resolveSource`, and every fetched document goes +//! through `plugins.marketplace.validateIndex` with this host's platform and +//! capability declaration before it may become the cached answer. A refresh +//! that fails anywhere — transport, budget, deadline, cancellation, invalid +//! JSON, Core rejection — never overwrites the last-known-good index, and a +//! refresh that started after a newer one never overwrites that newer one's +//! cache either. The cache keeps the validated raw input, and offline reads +//! revalidate it with the running Core and current host capabilities. Availability +//! statuses come only from Core: a legal index says nothing about whether +//! an archive or its signature was verified. + +use super::marketplace_download::{ + fetch_controlled, CancelToken, FetchFailure, FetchLimits, FetchPolicy, HttpTransport, + PendingFile, MAX_INDEX_JSON_BYTES, MAX_REDIRECT_HOPS, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +/// Who maintains a source, in Core's `sourceKind` vocabulary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SourceKind { + Official, + ThirdParty, +} + +/// One refresh request for an already-resolved source. The `index_url` comes +/// from Core's `resolveSource` outcome; this layer never parses user URLs. +#[derive(Debug, Clone)] +pub struct IndexRefreshRequest { + /// Stable source identity from the host's source management; used as the + /// cache key and validated as a path component. + pub source_id: String, + pub index_url: String, + pub source_kind: SourceKind, + /// This host's platform identifier, e.g. `windows-x64`. + pub platform: String, + /// This host's capability declaration, as Core expects it. + pub host_capabilities: Value, + /// Overall deadline for the fetch, including redirects. + pub deadline: Duration, +} + +/// The outcome of one successful refresh against the live source. +#[derive(Debug, Clone)] +pub struct RefreshedIndex { + pub source_id: String, + pub platform: String, + /// The Core-validated index (`{plugins, rejected}`) for this platform. + pub index: Value, + /// URL the document was finally served from. + pub final_url: String, + /// Whether this refresh became the cached last-known-good answer. An + /// older request that finishes after a newer one reports `false`. + pub persisted: bool, +} + +/// Every way a refresh can fail, with a stable kind for the marketplace +/// surface. None of these ever touch the cached last-known-good index. +#[derive(Debug, Clone, PartialEq)] +pub enum IndexRefreshFailure { + Cancelled, + DeadlineExceeded, + Transport(String), + BudgetExceeded, + InvalidJson(String), + /// Core rejected the whole index; the code is Core's stable code. + Rejected { + code: String, + message: String, + }, + /// The index URL answered with a non-success status. + HttpError { + status: u16, + url: String, + }, + /// Core itself failed (malformed envelope, host bug). + Core(String), + CacheWrite(String), +} + +impl IndexRefreshFailure { + /// Stable failure kind for the marketplace surface. + pub fn kind(&self) -> &'static str { + match self { + IndexRefreshFailure::Cancelled => "cancelled", + IndexRefreshFailure::DeadlineExceeded => "deadline", + IndexRefreshFailure::Transport(_) => "transport", + IndexRefreshFailure::BudgetExceeded => "budgetExceeded", + IndexRefreshFailure::InvalidJson(_) => "invalidJson", + IndexRefreshFailure::HttpError { .. } => "httpError", + IndexRefreshFailure::Rejected { .. } => "indexRejected", + IndexRefreshFailure::Core(_) => "coreFailure", + IndexRefreshFailure::CacheWrite(_) => "cacheWrite", + } + } + + pub fn message(&self) -> String { + match self { + IndexRefreshFailure::Cancelled => "the index refresh was cancelled".to_string(), + IndexRefreshFailure::DeadlineExceeded => { + "the index refresh exceeded its deadline".to_string() + } + IndexRefreshFailure::Transport(error) => format!("the index fetch failed: {error}"), + IndexRefreshFailure::BudgetExceeded => { + format!("the index exceeds the {MAX_INDEX_JSON_BYTES} byte budget") + } + IndexRefreshFailure::InvalidJson(error) => { + format!("the index is not valid JSON: {error}") + } + IndexRefreshFailure::HttpError { status, url } => { + format!("the index fetch of {url} returned HTTP {status}") + } + IndexRefreshFailure::Rejected { message, .. } => message.clone(), + IndexRefreshFailure::Core(error) => error.clone(), + IndexRefreshFailure::CacheWrite(error) => { + format!("the index cache could not be written: {error}") + } + } + } +} + +impl From for IndexRefreshFailure { + fn from(failure: FetchFailure) -> Self { + match failure { + FetchFailure::Cancelled => IndexRefreshFailure::Cancelled, + FetchFailure::DeadlineExceeded => IndexRefreshFailure::DeadlineExceeded, + FetchFailure::BudgetExceeded { .. } => IndexRefreshFailure::BudgetExceeded, + FetchFailure::HttpError { status, url } => { + IndexRefreshFailure::HttpError { status, url } + } + FetchFailure::Transport(error) => IndexRefreshFailure::Transport(error), + other => IndexRefreshFailure::Transport(other.message()), + } + } +} + +/// One availability answer read back from a Core-validated index. The +/// variants mirror Core's own statuses so the marketplace surface can +/// distinguish "the source is broken" from "this plugin cannot run here". +#[derive(Debug, Clone, PartialEq)] +pub enum Availability { + Available { + version: String, + download_url: String, + sha256: String, + }, + /// Core reported the plugin as unusable on this host (`apiUnsupported` + /// or `platformUnsupported`). + Unsupported { code: String, message: String }, + /// The ID is not listed by this index. + NotFound, +} + +/// Reads one plugin's availability out of a Core-validated index document. +pub fn availability_of(validated_index: &Value, plugin_id: &str) -> Availability { + let Some(plugins) = validated_index["plugins"].as_array() else { + return Availability::NotFound; + }; + let Some(plugin) = plugins + .iter() + .find(|plugin| plugin["id"].as_str() == Some(plugin_id)) + else { + return Availability::NotFound; + }; + let availability = &plugin["availability"]; + match availability["status"].as_str() { + Some("available") => Availability::Available { + version: availability["version"] + .as_str() + .unwrap_or_default() + .to_string(), + download_url: availability["downloadURL"] + .as_str() + .unwrap_or_default() + .to_string(), + sha256: availability["sha256"] + .as_str() + .unwrap_or_default() + .to_string(), + }, + Some(status) => Availability::Unsupported { + code: availability["code"] + .as_str() + .map(str::to_string) + .unwrap_or_else(|| status.to_string()), + message: availability["message"] + .as_str() + .unwrap_or_default() + .to_string(), + }, + _ => Availability::NotFound, + } +} + +/// Per-source index cache over the caller's runtime state root. All writes +/// land in the caller-provided writable root (`app_local_data_dir` state, or +/// the existing plugin-temp in tests) — never in installation or distribution +/// resources. Same-source publications are serialized per key, and a refresh +/// with a newer generation always wins the cache. +pub struct MarketplaceIndexStore { + root: PathBuf, + /// Highest started generation per `source_id.platform` key. + generations: Mutex>, +} + +/// Versioned raw input, never a cached Core availability answer. Revalidation +/// on every read uses the running Core and the caller's current host context. +/// Legacy normalized caches lack this envelope and are treated as cache misses. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CachedIndex { + cache_format: u32, + source_id: String, + index_url: String, + raw_index: Value, +} + +impl MarketplaceIndexStore { + pub fn new(root: PathBuf) -> Self { + Self { + root, + generations: Mutex::new(HashMap::new()), + } + } + + /// Reads the last-known-good raw index offline and asks the current Core + /// for availability with the current source kind, platform and capabilities. + pub fn cached_index(&self, request: &IndexRefreshRequest) -> Result, String> { + let path = self.cache_path(&request.source_id, &request.platform)?; + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.to_string()), + }; + let envelope: Value = serde_json::from_str(&text) + .map_err(|error| format!("the cached index is unreadable: {error}"))?; + if envelope["cacheFormat"] != 1 { + return Ok(None); + } + let cached: CachedIndex = serde_json::from_value(envelope) + .map_err(|error| format!("the cached index is unreadable: {error}"))?; + if cached.source_id != request.source_id || cached.index_url != request.index_url { + return Ok(None); + } + validate_index(request, &cached.raw_index) + .map(Some) + .map_err(|error| error.message()) + } + + /// Fetches, validates, and (when still the newest request for the key) + /// persists one index. The fetch is bounded by the request deadline, the + /// JSON budget, the redirect limit, the HTTPS policy, and the cancel + /// token; a cancelled or failed refresh leaves the cache untouched. + pub async fn refresh( + &self, + transport: &dyn HttpTransport, + request: &IndexRefreshRequest, + policy: FetchPolicy, + cancel: &CancelToken, + ) -> Result { + self.refresh_with_validator(transport, request, policy, cancel, validate_index) + .await + } + + async fn refresh_with_validator( + &self, + transport: &dyn HttpTransport, + request: &IndexRefreshRequest, + policy: FetchPolicy, + cancel: &CancelToken, + validate: impl FnOnce(&IndexRefreshRequest, &Value) -> Result, + ) -> Result { + let key = + cache_key(&request.source_id, &request.platform).map_err(IndexRefreshFailure::Core)?; + let generation = { + let mut generations = self.generations.lock().map_err(|_| { + IndexRefreshFailure::CacheWrite("the generation map is poisoned".into()) + })?; + let generation = generations.entry(key.clone()).or_insert(0); + *generation += 1; + *generation + }; + if cancel.is_cancelled() { + return Err(IndexRefreshFailure::Cancelled); + } + let deadline = Instant::now() + request.deadline; + let fetch = fetch_controlled( + transport, + &request.index_url, + FetchLimits { + max_bytes: MAX_INDEX_JSON_BYTES, + max_redirects: MAX_REDIRECT_HOPS, + }, + policy, + cancel, + deadline, + ) + .await?; + if cancel.is_cancelled() { + return Err(IndexRefreshFailure::Cancelled); + } + let index_value: Value = serde_json::from_slice(&fetch.body) + .map_err(|error| IndexRefreshFailure::InvalidJson(error.to_string()))?; + let validated = validate(request, &index_value)?; + cancel.check(deadline)?; + // Only the newest started refresh for this key may persist: an older + // request finishing late returns its answer to its caller without + // overwriting the newer cached one. + let persisted = { + let mut generations = self.generations.lock().map_err(|_| { + IndexRefreshFailure::CacheWrite("the generation map is poisoned".into()) + })?; + let newest = generations.entry(key.clone()).or_insert(0); + cancel.check(deadline)?; + let is_newest = generation == *newest; + if is_newest { + self.persist( + &key, + &CachedIndex { + cache_format: 1, + source_id: request.source_id.clone(), + index_url: request.index_url.clone(), + raw_index: index_value, + }, + cancel, + deadline, + )?; + } + is_newest + }; + Ok(RefreshedIndex { + source_id: request.source_id.clone(), + platform: request.platform.clone(), + index: validated, + final_url: fetch.final_url, + persisted, + }) + } + + fn persist( + &self, + key: &str, + cached: &CachedIndex, + cancel: &CancelToken, + deadline: Instant, + ) -> Result<(), IndexRefreshFailure> { + self.persist_with_rename(key, cached, cancel, deadline, |source, target| { + std::fs::rename(source, target) + }) + } + + fn persist_with_rename( + &self, + key: &str, + cached: &CachedIndex, + cancel: &CancelToken, + deadline: Instant, + rename: impl FnOnce(&Path, &Path) -> std::io::Result<()>, + ) -> Result<(), IndexRefreshFailure> { + cancel.check(deadline)?; + std::fs::create_dir_all(&self.root) + .map_err(|error| IndexRefreshFailure::CacheWrite(error.to_string()))?; + let final_path = self.root.join(format!("{key}.json")); + let temp_path = self.root.join(format!( + "{key}.{}.tmp", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default() + )); + let bytes = serde_json::to_vec(cached) + .map_err(|error| IndexRefreshFailure::CacheWrite(error.to_string()))?; + let pending = PendingFile::write(temp_path, &bytes) + .map_err(|error| IndexRefreshFailure::CacheWrite(error.to_string()))?; + // Generation ownership is still held by refresh. Cancellation owns a + // separate, short commit lock, so it can win during Core/JSON or IO work. + cancel + .commit(deadline, || pending.publish_with(&final_path, rename))? + .map_err(|error| IndexRefreshFailure::CacheWrite(error.to_string())) + } + + fn cache_path(&self, source_id: &str, platform: &str) -> Result { + let key = cache_key(source_id, platform)?; + Ok(self.root.join(format!("{key}.json"))) + } +} + +fn validate_index( + request: &IndexRefreshRequest, + raw: &Value, +) -> Result { + let outcome = super::scan::core_execute( + "plugins.marketplace.validateIndex", + serde_json::json!({ + "index": raw, + "sourceKind": request.source_kind, + "platform": request.platform, + "hostCapabilities": request.host_capabilities, + }), + ) + .map_err(IndexRefreshFailure::Core)?; + match outcome["status"].as_str() { + Some("ok") if outcome["index"].is_object() => Ok(outcome["index"].clone()), + Some("rejected") => Err(IndexRefreshFailure::Rejected { + code: outcome["error"]["code"] + .as_str() + .unwrap_or("plugin.index.invalid") + .into(), + message: outcome["error"]["message"] + .as_str() + .unwrap_or("the index was rejected") + .into(), + }), + _ => Err(IndexRefreshFailure::Core( + "validateIndex returned no valid outcome".into(), + )), + } +} + +/// The cache key of one source and platform. Source IDs come from the host's +/// source management and must already be slug-shaped; the check keeps a bad +/// ID from escaping the cache root as a path. +fn cache_key(source_id: &str, platform: &str) -> Result { + let key = format!("{source_id}.{platform}"); + let valid = !key.is_empty() + && key.len() <= 128 + && key.chars().all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || matches!(character, '.' | '-') + }) + && !key.split('.').any(|part| part == ".."); + if valid { + Ok(key) + } else { + Err(format!( + "the source id {source_id:?} is not a usable cache key" + )) + } +} + +/// Deadline used by callers that just want "bounded"; the marketplace wiring +/// decides the real values. Exported so tests and callers share one shape. +pub fn refresh_deadline(deadline: Duration) -> Instant { + Instant::now() + deadline +} + +/// Path of one cached index document, exposed for diagnostics and cleanup by +/// the source management layer. +pub fn cached_index_path(root: &Path, source_id: &str, platform: &str) -> Result { + Ok(root.join(format!("{}.json", cache_key(source_id, platform)?))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin_host::marketplace_download::tests::{TestHttpServer, TestResponse, TestRoot}; + use sha2::{Digest, Sha256}; + use std::future::Future; + use std::pin::Pin; + use std::sync::Arc; + + const MARKETPLACE_FIXTURES_DIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/contracts/plugins/v1/fixtures/marketplace" + ); + + fn read_marketplace_fixture(name: &str) -> Value { + serde_json::from_str( + &std::fs::read_to_string(format!("{MARKETPLACE_FIXTURES_DIR}/{name}")) + .expect("marketplace fixture readable"), + ) + .expect("marketplace fixture is JSON") + } + + struct BytesTransport(Vec); + + impl HttpTransport for BytesTransport { + fn get<'a>( + &'a self, + _url: &'a str, + _max_bytes: u64, + _deadline: Instant, + _cancel: &'a CancelToken, + ) -> Pin< + Box< + dyn Future< + Output = Result< + super::super::marketplace_download::HttpResponse, + FetchFailure, + >, + > + Send + + 'a, + >, + > { + Box::pin(async { + Ok(super::super::marketplace_download::HttpResponse { + status: 200, + headers: HashMap::new(), + body: self.0.clone(), + }) + }) + } + } + + fn cached_document(request: &IndexRefreshRequest) -> CachedIndex { + CachedIndex { + cache_format: 1, + source_id: request.source_id.clone(), + index_url: request.index_url.clone(), + raw_index: read_marketplace_fixture("index.json"), + } + } + + fn assert_only_cache(root: &Path, request: &IndexRefreshRequest) { + let names: Vec<_> = std::fs::read_dir(root) + .expect("cache root") + .map(|entry| entry.expect("entry").file_name()) + .collect(); + assert_eq!( + names, + vec![std::ffi::OsString::from(format!( + "{}.json", + cache_key(&request.source_id, &request.platform).expect("key") + ))] + ); + } + + #[tokio::test] + async fn offline_cache_revalidates_capability_upgrades_downgrades_and_source_kind() { + let root = TestRoot::new("capabilities"); + let store = MarketplaceIndexStore::new(root.0.clone()); + let mut request = refresh_request_with_capabilities( + "capabilities", + "https://example.com/index.json".into(), + serde_json::json!({}), + ); + let transport = BytesTransport( + serde_json::to_vec(&read_marketplace_fixture("index.json")).expect("raw"), + ); + let first = store + .refresh( + &transport, + &request, + FetchPolicy::https_only(), + &CancelToken::new(), + ) + .await + .expect("initial refresh"); + assert!(matches!( + availability_of(&first.index, "io.example.elixir-tools"), + Availability::Unsupported { .. } + )); + let path = store + .cache_path(&request.source_id, &request.platform) + .expect("path"); + let saved = std::fs::read(&path).expect("raw cache"); + let envelope: Value = serde_json::from_slice(&saved).expect("envelope"); + assert!( + envelope["rawIndex"]["plugins"][0]["versions"].is_array(), + "persist original version metadata, never normalized availability" + ); + // A new store stands for the next host launch. No transport is available + // on these reads; availability must still follow the current host/Core. + let restarted = MarketplaceIndexStore::new(root.0.clone()); + for (capabilities, version) in [ + ( + serde_json::json!({"languages":[1], "languageServers":[1], "serverProcess":[1]}), + "1.0.0", + ), + ( + serde_json::json!({"languages":[1], "languageServers":[1], "serverProcess":[1], "buildSystems":[1], "buildProviders":[1]}), + "2.0.0", + ), + ] { + request.host_capabilities = capabilities; + let current = restarted + .cached_index(&request) + .expect("offline read") + .expect("cache hit"); + assert!( + matches!(availability_of(¤t, "io.example.elixir-tools"), + Availability::Available { version: selected, .. } if selected == version) + ); + } + request.host_capabilities = serde_json::json!({}); + let downgraded = restarted + .cached_index(&request) + .expect("downgrade") + .expect("cache"); + assert!(matches!( + availability_of(&downgraded, "io.example.elixir-tools"), + Availability::Unsupported { .. } + )); + request.source_kind = SourceKind::Official; + let official = restarted + .cached_index(&request) + .expect("official") + .expect("cache"); + assert!(!matches!( + availability_of(&official, "dev.lithe.fixture.language-packaged-server"), + Availability::NotFound + )); + request.source_kind = SourceKind::ThirdParty; + let third_party = restarted + .cached_index(&request) + .expect("third party") + .expect("cache"); + assert_eq!( + availability_of(&third_party, "dev.lithe.fixture.language-packaged-server"), + Availability::NotFound + ); + assert_eq!(std::fs::read(path).expect("cache unchanged"), saved); + } + + #[test] + fn legacy_normalized_and_unknown_cache_formats_are_safe_misses() { + let root = TestRoot::new("legacy-cache"); + let store = MarketplaceIndexStore::new(root.0.clone()); + let request = refresh_request("legacy", "https://example.com/index.json".into()); + let path = store + .cache_path(&request.source_id, &request.platform) + .expect("path"); + let raw = read_marketplace_fixture("index.json"); + for document in [ + validate_index(&request, &raw).expect("normalized legacy"), + serde_json::json!({"cacheFormat": 99, "rawIndex": raw}), + ] { + let bytes = serde_json::to_vec(&document).expect("legacy bytes"); + std::fs::write(&path, &bytes).expect("seed legacy"); + assert!(store.cached_index(&request).expect("safe miss").is_none()); + assert_eq!(std::fs::read(&path).expect("preserved"), bytes); + } + let mut changed_origin = cached_document(&request); + changed_origin.index_url = "https://other.example.com/index.json".into(); + std::fs::write( + &path, + serde_json::to_vec(&changed_origin).expect("origin bytes"), + ) + .expect("seed"); + assert!(store + .cached_index(&request) + .expect("changed source URL") + .is_none()); + } + + #[test] + fn rename_failure_removes_the_staged_cache_and_preserves_last_known_good_bytes() { + let root = TestRoot::new("rename-failure"); + let store = MarketplaceIndexStore::new(root.0.clone()); + let request = refresh_request("retain", "https://example.com/index.json".into()); + let cached = cached_document(&request); + store + .persist( + "retain.windows-x64", + &cached, + &CancelToken::new(), + refresh_deadline(Duration::from_secs(2)), + ) + .expect("seed cache"); + let path = store + .cache_path(&request.source_id, &request.platform) + .expect("path"); + let saved = std::fs::read(&path).expect("old cache bytes"); + let mut changed = cached; + changed.raw_index["plugins"][0]["name"] = serde_json::json!("must not publish"); + let error = store + .persist_with_rename( + "retain.windows-x64", + &changed, + &CancelToken::new(), + refresh_deadline(Duration::from_secs(2)), + |source, target| { + assert!( + source.is_file(), + "write and flush completed before injected rename failure" + ); + assert_eq!(std::fs::read(target).expect("prior cache"), saved); + Err(std::io::Error::other("injected rename failure")) + }, + ) + .expect_err("rename fails"); + assert_eq!(error.kind(), "cacheWrite"); + assert_eq!(std::fs::read(path).expect("old cache remains"), saved); + assert_only_cache(&root.0, &request); + } + + #[tokio::test] + async fn cancellation_after_core_validation_cannot_replace_the_cache() { + let root = TestRoot::new("cancel-validation"); + let store = MarketplaceIndexStore::new(root.0.clone()); + let request = refresh_request("retain", "https://example.com/index.json".into()); + let cached = cached_document(&request); + store + .persist( + "retain.windows-x64", + &cached, + &CancelToken::new(), + refresh_deadline(Duration::from_secs(2)), + ) + .expect("seed"); + let path = store + .cache_path(&request.source_id, &request.platform) + .expect("path"); + let saved = std::fs::read(&path).expect("prior bytes"); + let mut raw = read_marketplace_fixture("index.json"); + raw["plugins"][0]["name"] = serde_json::json!("cancelled answer"); + let transport = BytesTransport(serde_json::to_vec(&raw).expect("raw bytes")); + let cancel = CancelToken::new(); + let result = store + .refresh_with_validator( + &transport, + &request, + FetchPolicy::https_only(), + &cancel, + |request, raw| { + let validated = validate_index(request, raw)?; + cancel.cancel(); + Ok(validated) + }, + ) + .await; + assert_eq!( + result.expect_err("cancelled before publication"), + IndexRefreshFailure::Cancelled + ); + assert_eq!(std::fs::read(path).expect("cache remains"), saved); + assert_only_cache(&root.0, &request); + } + + fn refresh_request(source_id: &str, url: String) -> IndexRefreshRequest { + refresh_request_with_capabilities(source_id, url, super::super::host_capabilities()) + } + + fn refresh_request_with_capabilities( + source_id: &str, + url: String, + host_capabilities: Value, + ) -> IndexRefreshRequest { + IndexRefreshRequest { + source_id: source_id.to_string(), + index_url: url, + source_kind: SourceKind::ThirdParty, + platform: "windows-x64".to_string(), + host_capabilities, + deadline: Duration::from_secs(10), + } + } + + /// The refreshed index must answer exactly like the shared fixture case + /// for windows-x64: the same availability statuses, versions, digests, + /// and the same refused entries in index order. + #[tokio::test] + async fn refresh_matches_the_shared_standard_answer() { + let index = read_marketplace_fixture("index.json"); + let case = read_marketplace_fixture("index-cases.json")["cases"] + .as_array() + .expect("cases") + .iter() + .find(|case| { + case["name"] + == serde_json::json!("a capability Core cannot validate is never offered") + }) + .expect("the capability-gated case") + .clone(); + let server = TestHttpServer::start(vec![( + "/index.json", + vec![TestResponse::ok( + serde_json::to_vec(&index).expect("index bytes"), + )], + )]); + let root = TestRoot::new("golden"); + let store = MarketplaceIndexStore::new(root.0.clone()); + let refreshed = store + .refresh( + &crate::plugin_host::marketplace_download::ReqwestTransport::new() + .expect("transport"), + &refresh_request_with_capabilities( + "golden", + format!("{}/index.json", server.base_url), + case["hostCapabilities"].clone(), + ), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + ) + .await + .expect("the refresh succeeds"); + assert!(refreshed.persisted); + + // Availability answers come from Core, entry by entry. + let expected_plugins = case["expected"]["plugins"] + .as_array() + .expect("expected plugins"); + assert_eq!( + refreshed.index["plugins"] + .as_array() + .expect("plugins") + .len(), + expected_plugins.len() + ); + for expected in expected_plugins { + let id = expected["id"].as_str().expect("id"); + let expected_status = expected["availability"]["status"].as_str().expect("status"); + match availability_of(&refreshed.index, id) { + Availability::Available { + version, + download_url, + sha256, + } => { + assert_eq!(expected_status, "available", "{id}"); + assert_eq!(version, expected["availability"]["version"]); + assert_eq!( + sha256, + expected["availability"]["sha256"].as_str().expect("sha") + ); + assert!( + !download_url.is_empty(), + "an available entry carries the download URL" + ); + } + Availability::Unsupported { code, message } => { + assert_eq!(expected_status, "unsupported", "{id}"); + assert_eq!( + code, + expected["availability"]["code"].as_str().expect("code"), + "{id}" + ); + assert!(!message.is_empty()); + } + Availability::NotFound => panic!("the listed plugin {id} went missing"), + } + } + // The refused entries keep Core's stable codes. + let expected_rejected = case["expected"]["rejected"] + .as_array() + .expect("expected rejected"); + let rejected = refreshed.index["rejected"] + .as_array() + .expect("rejected") + .clone(); + for expected in expected_rejected { + let id = expected["id"].as_str().expect("id"); + let code = expected["code"].as_str().expect("code"); + assert!( + rejected.iter().any(|entry| entry["id"] == expected["id"] + && entry["error"]["code"] == serde_json::json!(code)), + "the refused entry {id} must report {code}: {rejected:?}" + ); + } + } + + /// A failed refresh — invalid JSON, a Core-rejected index, an HTTP + /// failure, a budget overrun — never overwrites the last-known-good + /// cache. + #[tokio::test] + async fn failed_refreshes_keep_the_last_known_good_index() { + let index = read_marketplace_fixture("index.json"); + let index_bytes = serde_json::to_vec(&index).expect("index bytes"); + let broken_cases: Vec<&str> = vec!["not-json", "no-schema", "missing", "budget"]; + let server = TestHttpServer::start(vec![ + ("/good", vec![TestResponse::ok(index_bytes)]), + ("/not-json", vec![TestResponse::ok(b"{ not json".to_vec())]), + ( + "/no-schema", + vec![TestResponse::ok(br#"{"plugins": []}"#.to_vec())], + ), + ("/missing", vec![TestResponse::with_status(404)]), + ( + "/budget", + // The index budget is 8 MiB; a 9 MiB body exceeds it and the + // transport aborts mid-body at the budget. + vec![TestResponse::ok(vec![b'x'; 9 * 1024 * 1024])], + ), + ]); + let root = TestRoot::new("retain"); + let store = MarketplaceIndexStore::new(root.0.clone()); + let transport = + crate::plugin_host::marketplace_download::ReqwestTransport::new().expect("transport"); + + let good = store + .refresh( + &transport, + &refresh_request("retain", format!("{}/good", server.base_url)), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + ) + .await + .expect("the good refresh succeeds"); + assert!(good.persisted); + + for label in &broken_cases { + let error = store + .refresh( + &transport, + &refresh_request("retain", format!("{}/{label}", server.base_url)), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + ) + .await + .expect_err("the broken refresh must fail"); + match *label { + "not-json" => assert_eq!(error.kind(), "invalidJson", "{error:?}"), + "no-schema" => { + assert_eq!(error.kind(), "indexRejected", "{error:?}"); + assert!(error.message().contains("schemaVersion"), "{error:?}"); + } + "missing" => assert_eq!(error.kind(), "httpError", "{error:?}"), + "budget" => assert_eq!(error.kind(), "budgetExceeded", "{error:?}"), + other => panic!("unexpected broken case {other}: {error:?}"), + } + // The failure kinds are distinguishable per case, and the cache + // still holds the good answer. + let cached = store + .cached_index(&refresh_request( + "retain", + format!("{}/good", server.base_url), + )) + .expect("cache readable") + .expect("last known good kept"); + assert!(cached["plugins"].as_array().expect("plugins").len() >= 3); + } + } + + /// A refresh that started before a newer one but finishes after it never + /// overwrites the newer cache entry; it still answers its own caller. + #[tokio::test] + async fn older_refresh_finishing_late_never_overwrites_the_newer_cache() { + use crate::plugin_host::marketplace_download::HttpTransport; + + let mut index_old = read_marketplace_fixture("index.json"); + let mut index_new = read_marketplace_fixture("index.json"); + for (document, name) in [ + (&mut index_old, "Older Refresh Name"), + (&mut index_new, "Newer Refresh Name"), + ] { + // Core sorts the validated plugins by ID, so the elixir entry is + // the first of the cached document; both refreshes mutate it. + let entry = document["plugins"] + .as_array_mut() + .expect("plugins") + .iter_mut() + .find(|entry| entry["id"] == serde_json::json!("io.example.elixir-tools")) + .expect("elixir entry"); + entry["name"] = serde_json::json!(name); + } + + let server = TestHttpServer::start(vec![ + ( + "/old", + vec![TestResponse::ok( + serde_json::to_vec(&index_old).expect("old bytes"), + )], + ), + ( + "/new", + vec![TestResponse::ok( + serde_json::to_vec(&index_new).expect("new bytes"), + )], + ), + ]); + struct GatedTransport { + inner: crate::plugin_host::marketplace_download::ReqwestTransport, + gate_path: String, + entered: std::sync::Mutex>>, + release: Arc, + } + impl HttpTransport for GatedTransport { + fn get<'a>( + &'a self, + url: &'a str, + max_bytes: u64, + deadline: Instant, + cancel: &'a CancelToken, + ) -> Pin< + Box< + dyn Future< + Output = Result< + crate::plugin_host::marketplace_download::HttpResponse, + FetchFailure, + >, + > + Send + + 'a, + >, + > { + let entered = self + .entered + .lock() + .expect("entered") + .take() + .filter(|_| url.contains(&self.gate_path)); + let release = self.release.clone(); + Box::pin(async move { + if let Some(entered) = entered { + let _ = entered.send(()); + // Bounded: the test releases within its own runtime. + tokio::time::timeout(Duration::from_secs(10), release.notified()) + .await + .map_err(|_| FetchFailure::DeadlineExceeded)?; + } + self.inner.get(url, max_bytes, deadline, cancel).await + }) + } + } + + let root = TestRoot::new("order"); + let store = Arc::new(MarketplaceIndexStore::new(root.0.clone())); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel::<()>(); + let release = Arc::new(tokio::sync::Notify::new()); + let gated = Arc::new(GatedTransport { + inner: crate::plugin_host::marketplace_download::ReqwestTransport::new() + .expect("transport"), + gate_path: "/old".to_string(), + entered: std::sync::Mutex::new(Some(entered_tx)), + release: release.clone(), + }); + let plain = + crate::plugin_host::marketplace_download::ReqwestTransport::new().expect("transport"); + let old_url = format!("{}/old", server.base_url); + let new_url = format!("{}/new", server.base_url); + + // Both futures are owned by this scope. If an assertion unwinds, the + // parked request is dropped rather than left in a detached Tokio task. + let old_request = refresh_request("order", old_url); + let old_cancel = CancelToken::new(); + let older = store.refresh( + gated.as_ref(), + &old_request, + FetchPolicy::loopback_http_for_tests(), + &old_cancel, + ); + tokio::pin!(older); + tokio::select! { + result = &mut older => panic!("older request must be gated: {result:?}"), + entered = tokio::time::timeout(Duration::from_secs(2), entered_rx) => { + entered.expect("older request enters within its bound").expect("gate sender alive"); + } + } + + let newer = store + .refresh( + &plain, + &refresh_request("order", new_url), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + ) + .await + .expect("the newer refresh succeeds"); + assert!(newer.persisted); + + release.notify_one(); + let older = tokio::time::timeout(Duration::from_secs(2), &mut older) + .await + .expect("the older refresh finishes within its bound") + .expect("the older refresh still answers its caller"); + assert!( + !older.persisted, + "the late finishing refresh must not overwrite the newer cache" + ); + let cached = store + .cached_index(&refresh_request( + "order", + format!("{}/new", server.base_url), + )) + .expect("cache readable") + .expect("the newer answer is cached"); + assert_eq!( + cached["plugins"][0]["name"], + serde_json::json!("Newer Refresh Name"), + "the cached index is the newer refresh's document" + ); + } + + #[tokio::test] + async fn cancelled_and_deadline_refreshes_keep_the_cache() { + let index = read_marketplace_fixture("index.json"); + let server = TestHttpServer::start(vec![( + "/index.json", + vec![TestResponse::ok( + serde_json::to_vec(&index).expect("index bytes"), + )], + )]); + let root = TestRoot::new("cancel"); + let store = MarketplaceIndexStore::new(root.0.clone()); + let transport = + crate::plugin_host::marketplace_download::ReqwestTransport::new().expect("transport"); + + let good = store + .refresh( + &transport, + &refresh_request("cancel", format!("{}/index.json", server.base_url)), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + ) + .await + .expect("the good refresh succeeds"); + assert!(good.persisted); + + let cancel = CancelToken::new(); + cancel.cancel(); + let error = store + .refresh( + &transport, + &refresh_request("cancel", format!("{}/index.json", server.base_url)), + FetchPolicy::loopback_http_for_tests(), + &cancel, + ) + .await + .expect_err("the cancelled refresh must fail"); + assert_eq!(error.kind(), "cancelled"); + + let mut expired = refresh_request("cancel", format!("{}/index.json", server.base_url)); + expired.deadline = Duration::ZERO; + let error = store + .refresh( + &transport, + &expired, + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + ) + .await + .expect_err("the expired refresh must fail"); + assert_eq!(error.kind(), "deadline"); + + let cached = store + .cached_index(&refresh_request( + "cancel", + format!("{}/index.json", server.base_url), + )) + .expect("cache readable") + .expect("kept"); + assert!(cached["plugins"].as_array().expect("plugins").len() >= 3); + } + + /// Unusable cache keys are refused instead of escaping the cache root, + /// and the SHA-256 the index publishes is the one a download binds to. + #[test] + fn cache_keys_are_validated_and_sha256_round_trips() { + let root = TestRoot::new("keys"); + let store = MarketplaceIndexStore::new(root.0.clone()); + assert!(store + .cached_index(&refresh_request( + "../evil", + "https://example.com/index.json".into() + )) + .is_err()); + assert!(store + .cached_index(&refresh_request( + "UPPER", + "https://example.com/index.json".into() + )) + .is_err()); + assert!(store + .cached_index(&refresh_request( + "official", + "https://example.com/index.json".into() + )) + .expect("usable key") + .is_none()); + + let artifact_sha = { + let digest = Sha256::digest(b"future artifact"); + format!("{:x}", digest) + }; + assert_eq!(artifact_sha.len(), 64); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/marketplace_sources.rs b/windows/tauri/src-tauri/src/plugin_host/marketplace_sources.rs new file mode 100644 index 0000000000..8c38a48fac --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/marketplace_sources.rs @@ -0,0 +1,501 @@ +//! Marketplace source list for declarative plugins: the preset sources the +//! host rebuilds from constants, user-added third-party sources, enable +//! state, and their persistence under `app_local_data_dir()`. +//! +//! Semantics mirror the macOS feature model +//! (`macos/Sources/Lithe/Application/Features/PluginMarketplace.swift`) and +//! its adapter (`MacPluginMarketplaceService`): the official source can be +//! disabled but never removed, Lithe Store is a removable preset that stays +//! removed across restarts, source IDs are the same FNV-1a derivation over +//! the index URL, and URL resolution is decided by Core +//! (`plugins.marketplace.resolveSource`) — never reinterpreted here. Index +//! fetching, downloads, and installation are later M2 slices and do not +//! belong to this module. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; +use tauri::{AppHandle, Manager}; + +/// The official source: host constant, rebuildable, undeletable. +pub const OFFICIAL_SOURCE_ID: &str = "lithe-official"; +pub const OFFICIAL_INDEX_URL: &str = + "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/HEAD/Plugins/marketplace/index.json"; +pub const OFFICIAL_REPOSITORY: &str = "https://github.com/Lithe-IDEA/Lithe-IDEA"; +/// Lithe Store, the preset community source. Unlike the official source it +/// is third-party and the user may remove it; removal is remembered so the +/// next launch does not add it back. +pub const COMMUNITY_REPOSITORY: &str = "https://github.com/Lithe-IDEA/Lithe-store"; +pub const COMMUNITY_INDEX_URL: &str = + "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-store/HEAD/lithe-plugin-index.json"; + +const SOURCES_SCHEMA_VERSION: u64 = 1; +const OFFICIAL_SOURCE_NAME: &str = "Lithe"; +const COMMUNITY_SOURCE_NAME: &str = "Lithe Store"; + +/// The largest number of corruption backups one store keeps before giving +/// up; a backup must never overwrite a previous one, so disk-exhausting +/// loops terminate with a diagnosable error instead. +pub(crate) const MAXIMUM_CORRUPTION_BACKUPS: u32 = 64; + +/// Serializes every sources.json read-modify-write across the host's +/// windows: each command loads, mutates, and persists the whole list, so +/// two concurrent transactions would otherwise drop one side's update. +/// The store is one file per user, so a process-wide lock over the +/// transaction is the exact scope needed. +static SOURCES_LOCK: Mutex<()> = Mutex::new(()); + +/// Distinguishes concurrent temp files of the same store (multi-window +/// writes and, in principle, several app instances) so a fixed temp name +/// can never collide mid-rename. +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +pub(crate) fn sources_lock() -> &'static Mutex<()> { + &SOURCES_LOCK +} + +/// One marketplace source. Serialized field names match the macOS +/// `sources.json` shape so the stored bytes stay cross-platform readable. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct MarketplaceSource { + pub id: String, + pub name: String, + /// What the user entered, or the official index URL for a preset. + pub input: String, + #[serde(rename = "indexURL")] + pub index_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository: Option, + #[serde(rename = "isEnabled")] + pub is_enabled: bool, +} + +/// Stable, filesystem-safe ID for a source, derived from its index URL with +/// the exact FNV-1a derivation macOS uses +/// (`PluginMarketplaceSource.id(forIndexURL:)`): offset basis, FNV prime, +/// then `source-` plus lowercase hexadecimal without padding. The same URL +/// must therefore never be addable twice. +pub(crate) fn source_id_for_index_url(index_url: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in index_url.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("source-{hash:x}") +} + +/// Whether two index URLs name the same index. GitHub hosts lowercase their +/// addresses, and a user may type the preset's URL in a different case, so +/// the comparison is Unicode case-insensitive like macOS +/// `caseInsensitiveCompare`. +fn same_index(left: &str, right: &str) -> bool { + left.to_lowercase() == right.to_lowercase() +} + +fn official_source() -> MarketplaceSource { + MarketplaceSource { + id: OFFICIAL_SOURCE_ID.to_string(), + name: OFFICIAL_SOURCE_NAME.to_string(), + input: OFFICIAL_INDEX_URL.to_string(), + index_url: OFFICIAL_INDEX_URL.to_string(), + repository: Some(OFFICIAL_REPOSITORY.to_string()), + is_enabled: true, + } +} + +fn community_source() -> MarketplaceSource { + MarketplaceSource { + id: source_id_for_index_url(COMMUNITY_INDEX_URL), + name: COMMUNITY_SOURCE_NAME.to_string(), + input: COMMUNITY_REPOSITORY.to_string(), + index_url: COMMUNITY_INDEX_URL.to_string(), + repository: Some(COMMUNITY_REPOSITORY.to_string()), + is_enabled: true, + } +} + +/// `sources.json` next to the other plugin-host stores. Writes only ever +/// target `app_local_data_dir()`; installed packages stay read-only. +pub(crate) fn sources_path(app: &AppHandle) -> Result { + Ok(app + .path() + .app_local_data_dir() + .map_err(|error| error.to_string())? + .join("plugin-marketplace") + .join("sources.json")) +} + +/// The persisted shape. `removedPresetSourceIDs` records presets the user +/// removed so a restart does not add them back. +#[derive(Deserialize, Serialize)] +struct SourcesFile { + #[serde(rename = "schemaVersion")] + schema_version: u64, + #[serde(default)] + sources: Vec, + #[serde( + default, + rename = "removedPresetSourceIDs", + skip_serializing_if = "Option::is_none" + )] + removed_preset_source_ids: Option>, +} + +/// Rebuilds the preset sources from host constants; only their enable state +/// is the user's. A user-added source typed under a differently cased +/// Lithe Store address stands in for the preset, keeping the ID its plugins +/// already recorded — the same rule as macOS `loadSources`. Takes the host +/// sources lock because a corrupted store is moved aside while reading. +pub(crate) fn load_sources( + path: &Path, +) -> Result<(Vec, Option), String> { + let _guard = sources_lock() + .lock() + .map_err(|error| format!("plugin.marketplace.sources.lock: {error}"))?; + load_sources_unlocked(path) +} + +pub(crate) fn load_sources_unlocked( + path: &Path, +) -> Result<(Vec, Option), String> { + let mut official = official_source(); + let mut community = community_source(); + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok((vec![official, community], None)); + } + Err(error) => return Err(error.to_string()), + }; + let parsed: Result = serde_json::from_str(&text); + let file: SourcesFile = match parsed { + Ok(file) if file.schema_version == SOURCES_SCHEMA_VERSION => file, + Ok(file) => { + let problem = format!( + "plugin.marketplace.sources.corrupt: schemaVersion {} is not supported", + file.schema_version + ); + return Ok((preserve_corrupt_file(path)?, Some(problem))); + } + Err(error) => { + let problem = format!("plugin.marketplace.sources.corrupt: {error}"); + return Ok((preserve_corrupt_file(path)?, Some(problem))); + } + }; + official.is_enabled = file + .sources + .iter() + .find(|source| source.id == official.id) + .map(|source| source.is_enabled) + .unwrap_or(true); + // Lithe Store added by hand before it became a preset already has the + // preset ID, so only its enabled state carries over (macOS rule). + community.is_enabled = file + .sources + .iter() + .find(|source| source.id == community.id) + .map(|source| source.is_enabled) + .unwrap_or(true); + let preset_ids = [OFFICIAL_SOURCE_ID.to_string(), community.id.clone()]; + let third_party: Vec = file + .sources + .iter() + .filter(|source| !preset_ids.contains(&source.id)) + .cloned() + .collect(); + if file + .removed_preset_source_ids + .as_ref() + .is_some_and(|removed| removed.contains(&community.id)) + { + return Ok((prepend_official(official, third_party), None)); + } + if third_party + .iter() + .any(|source| same_index(&source.index_url, &community.index_url)) + { + return Ok((prepend_official(official, third_party), None)); + } + Ok(( + prepend_official( + official, + std::iter::once(community).chain(third_party).collect(), + ), + None, + )) +} + +fn prepend_official( + official: MarketplaceSource, + mut sources: Vec, +) -> Vec { + // The rebuild never lets a stored preset entry's name or URL stand in; + // the merged enable state lives in `official` already. + sources.insert(0, official); + sources +} + +/// A corrupted store must never be silently reset and never destroy +/// earlier backups: the original bytes move to the first free +/// `sources.json.corrupt-` (n = 1..64), keeping every historical copy, +/// and the list rebuilds from presets. A failed backup leaves the +/// unreadable file in place untouched. +fn preserve_corrupt_file(path: &Path) -> Result, String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + for attempt in 1..=MAXIMUM_CORRUPTION_BACKUPS { + let backup = path.with_extension(format!("json.corrupt-{attempt}")); + if backup.exists() { + continue; + } + return match std::fs::rename(path, &backup) { + Ok(()) => Ok(vec![official_source(), community_source()]), + Err(error) => Err(format!( + "plugin.marketplace.sources.corrupt: preserving the unreadable file failed: {error}" + )), + }; + } + Err(format!( + "plugin.marketplace.sources.corrupt: all {MAXIMUM_CORRUPTION_BACKUPS} backup slots exist; move them aside and retry" + )) +} + +/// Writes the store atomically via a unique temp file + rename, recomputing +/// the removed-preset tombstones from the list: a preset missing here was +/// removed by the user, and one present again clears its tombstone. +pub(crate) fn save_sources(path: &Path, sources: &[MarketplaceSource]) -> Result<(), String> { + let _guard = sources_lock() + .lock() + .map_err(|error| format!("plugin.marketplace.sources.lock: {error}"))?; + save_sources_unlocked(path, sources) +} + +fn save_sources_unlocked(path: &Path, sources: &[MarketplaceSource]) -> Result<(), String> { + let removed: Vec = [community_source().id] + .into_iter() + .filter(|id| !sources.iter().any(|source| &source.id == id)) + .collect(); + let file = SourcesFile { + schema_version: SOURCES_SCHEMA_VERSION, + sources: sources.to_vec(), + // macOS omits the key when nothing was removed; an absent key and an + // empty list mean the same thing to both loaders. + removed_preset_source_ids: if removed.is_empty() { + None + } else { + Some(removed) + }, + }; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let temp = path.with_extension(format!("json.tmp-{}-{sequence}", std::process::id())); + // A failed write or rename must not leave this attempt's temp file + // behind; the previous store stays untouched either way. + let result = std::fs::write( + &temp, + serde_json::to_string_pretty(&file).map_err(|error| error.to_string())?, + ) + .and_then(|()| std::fs::rename(&temp, path)) + .map_err(|error| error.to_string()); + if result.is_err() { + let _ = std::fs::remove_file(&temp); + } + result +} + +/// Adds a source for `input`. Core decides what the input names; the host +/// derives the stable ID, refuses duplicates (by ID or by case-insensitive +/// index URL), and keeps `isEnabled` of everything already listed. A +/// re-added Lithe Store takes its preset ID back in any casing, which also +/// clears its removal tombstone on the next save. The whole +/// load-resolve-mutate-persist sequence holds the host sources lock so +/// concurrent windows cannot drop each other's update. +pub(crate) fn add_source(path: &Path, input: &str) -> Result, String> { + let _guard = sources_lock() + .lock() + .map_err(|error| format!("plugin.marketplace.sources.lock: {error}"))?; + let input = input.trim(); + if input.is_empty() { + return Err("plugin.marketplace.sources.emptyInput".to_string()); + } + let resolution = resolve_via_core(input)?; + let index_url = resolution + .get("indexURL") + .and_then(Value::as_str) + .ok_or_else(|| "plugin.marketplace.sources.invalidResolution".to_string())? + .to_string(); + let repository = resolution + .get("repository") + .and_then(Value::as_str) + .map(str::to_string); + let name = resolution + .get("displayName") + .and_then(Value::as_str) + .unwrap_or(index_url.as_str()) + .to_string(); + let community = community_source(); + let id = if same_index(&index_url, &community.index_url) { + community.id + } else { + source_id_for_index_url(&index_url) + }; + let (mut sources, _) = load_sources_unlocked(path)?; + if sources + .iter() + .any(|source| source.id == id || same_index(&source.index_url, &index_url)) + { + return Err(format!("plugin.marketplace.sourceAlreadyAdded: {name}")); + } + sources.push(MarketplaceSource { + id, + name, + input: input.to_string(), + index_url, + repository, + is_enabled: true, + }); + save_sources_unlocked(path, &sources)?; + Ok(sources) +} + +/// Toggles one source's enable state under the host sources lock. An +/// unknown ID is a no-op, matching the macOS feature model. +pub(crate) fn set_enabled( + path: &Path, + source_id: &str, + enabled: bool, +) -> Result, String> { + let _guard = sources_lock() + .lock() + .map_err(|error| format!("plugin.marketplace.sources.lock: {error}"))?; + let (mut sources, _) = load_sources_unlocked(path)?; + if let Some(position) = sources.iter().position(|source| source.id == source_id) { + sources[position].is_enabled = enabled; + save_sources_unlocked(path, &sources)?; + } + Ok(sources) +} + +/// Removes a third-party source under the host sources lock. The official +/// source is refused; removing Lithe Store persists its tombstone so +/// restarts do not revive it. +pub(crate) fn remove_source( + path: &Path, + source_id: &str, +) -> Result, String> { + let _guard = sources_lock() + .lock() + .map_err(|error| format!("plugin.marketplace.sources.lock: {error}"))?; + let (sources, _) = load_sources_unlocked(path)?; + let Some(source) = sources.iter().find(|source| source.id == source_id) else { + return Err("plugin.marketplace.sourceNotFound".to_string()); + }; + if source.id == OFFICIAL_SOURCE_ID { + return Err("plugin.marketplace.officialSourceCannotBeRemoved".to_string()); + } + let remaining: Vec = sources + .into_iter() + .filter(|source| source.id != source_id) + .collect(); + save_sources_unlocked(path, &remaining)?; + Ok(remaining) +} + +/// One source as the frontend sees it: the stored fields plus the kind and +/// the actions the UI may offer. +pub(crate) fn source_view(source: &MarketplaceSource) -> Value { + let official = source.id == OFFICIAL_SOURCE_ID; + let mut value = serde_json::to_value(source).expect("source serializes"); + value["kind"] = json!(if official { "official" } else { "thirdParty" }); + value["isOfficial"] = json!(official); + value["deletable"] = json!(!official); + value +} + +fn source_views(sources: &[MarketplaceSource]) -> Vec { + sources.iter().map(source_view).collect() +} + +/// Calls Core for the one decision this module delegates: what the user's +/// input names. The error keeps Core's stable code so the UI can show +/// `plugin.source.invalid` instead of prose. Marketplace commands return +/// rejections in-band (`data.status: "rejected"` with the stable code); +/// malformed requests fail on the envelope itself. +fn resolve_via_core(input: &str) -> Result { + let request = json!({ + "command": "plugins.marketplace.resolveSource", + "payload": { "url": input }, + }); + let response_text = lithe_core::execute_json(&request.to_string()); + let response: Value = serde_json::from_str(&response_text).map_err(|error| { + format!("plugins.marketplace.resolveSource returned an unreadable response: {error}") + })?; + if response["ok"] == json!(true) { + let data = response["data"].clone(); + if data["status"] == json!("ok") { + return Ok(data["source"].clone()); + } + Err(rejection_message(&data["error"])) + } else { + Err(rejection_message(&response["error"])) + } +} + +fn rejection_message(error: &Value) -> String { + let code = error["code"].as_str().unwrap_or("unknown"); + let message = error["message"].as_str().unwrap_or("unknown error"); + format!("{code}: {message}") +} + +/// Tauri command: current sources plus the corruption diagnosis, if the +/// store had to be rebuilt. +#[tauri::command] +pub fn declarative_marketplace_sources_state(app: AppHandle) -> Result { + let path = sources_path(&app)?; + let (sources, problem) = load_sources(&path)?; + Ok(json!({ "sources": source_views(&sources), "problem": problem })) +} + +/// Tauri command: resolves and adds a third-party source, returning the +/// updated list. +#[tauri::command] +pub fn declarative_marketplace_sources_add( + app: AppHandle, + url: String, +) -> Result, String> { + let path = sources_path(&app)?; + let sources = add_source(&path, &url)?; + Ok(source_views(&sources)) +} + +/// Tauri command: enables or disables one source, returning the updated list. +#[tauri::command] +pub fn declarative_marketplace_sources_set_enabled( + app: AppHandle, + source_id: String, + enabled: bool, +) -> Result, String> { + let path = sources_path(&app)?; + let sources = set_enabled(&path, &source_id, enabled)?; + if !enabled { + super::marketplace::cancel_source(&app, &source_id); + } + Ok(source_views(&sources)) +} + +/// Tauri command: removes a deletable source, returning the updated list. +#[tauri::command] +pub fn declarative_marketplace_sources_remove( + app: AppHandle, + source_id: String, +) -> Result, String> { + let path = sources_path(&app)?; + let sources = remove_source(&path, &source_id)?; + super::marketplace::cancel_source(&app, &source_id); + Ok(source_views(&sources)) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/marketplace_sources_tests.rs b/windows/tauri/src-tauri/src/plugin_host/marketplace_sources_tests.rs new file mode 100644 index 0000000000..d03bd9c853 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/marketplace_sources_tests.rs @@ -0,0 +1,585 @@ +//! M2 source-management tests exercise the internal functions directly (the +//! Tauri commands are thin AppHandle wrappers): preset rebuild from host +//! constants, the FNV source ID standard answers shared with macOS, +//! duplicate and official-source refusal, the Lithe Store tombstone, and the +//! corrupted-store handling. Everything runs offline; URL resolution goes +//! through the real Core `plugins.marketplace.resolveSource` command, whose +//! golden answers live in the shared `source-urls.json` fixture. + +mod marketplace_sources_tests { + use crate::plugin_host::marketplace_sources::{ + add_source, load_sources, remove_source, save_sources, set_enabled, + source_id_for_index_url, source_view, COMMUNITY_INDEX_URL, MAXIMUM_CORRUPTION_BACKUPS, + OFFICIAL_INDEX_URL, OFFICIAL_SOURCE_ID, + }; + use serde_json::{json, Value}; + use std::path::{Path, PathBuf}; + + /// Fixed standard answers, matching the macOS FNV-1a derivation; the + /// expectations are literals, never recomputed by the code under test. + const COMMUNITY_SOURCE_ID: &str = "source-39170ddcae1870bf"; + const EXAMPLE_INDEX_URL: &str = + "https://raw.githubusercontent.com/example/lithe-plugins/HEAD/lithe-plugin-index.json"; + const EXAMPLE_SOURCE_ID: &str = "source-94ba41e9fc33730a"; + + fn unique_temp_root(label: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lithe-marketplace-sources-{label}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("temp root"); + root + } + + fn sources_path(root: &Path) -> PathBuf { + root.join("plugin-marketplace").join("sources.json") + } + + fn cleanup(root: &Path) { + let _ = std::fs::remove_dir_all(root); + } + + fn ids(sources: &[Value]) -> Vec { + sources + .iter() + .filter_map(|source| source["id"].as_str().map(str::to_string)) + .collect() + } + + #[test] + fn standard_source_ids_match_the_fixed_answers() { + assert_eq!( + source_id_for_index_url(COMMUNITY_INDEX_URL), + COMMUNITY_SOURCE_ID + ); + assert_eq!( + source_id_for_index_url(EXAMPLE_INDEX_URL), + EXAMPLE_SOURCE_ID + ); + // The official source is a host constant, not a hash. + assert_eq!(OFFICIAL_SOURCE_ID, "lithe-official"); + } + + #[test] + fn preset_sources_rebuild_from_host_constants() { + let root = unique_temp_root("presets"); + let (sources, problem) = load_sources(&sources_path(&root)).expect("defaults load"); + assert_eq!( + ids(&sources.iter().map(source_view).collect::>()), + vec![ + OFFICIAL_SOURCE_ID.to_string(), + COMMUNITY_SOURCE_ID.to_string(), + ] + ); + assert_eq!(sources[0].name, "Lithe"); + assert_eq!(sources[0].index_url, OFFICIAL_INDEX_URL); + assert_eq!(sources[1].name, "Lithe Store"); + assert_eq!(sources[1].id, COMMUNITY_SOURCE_ID); + assert!(sources[1] + .index_url + .starts_with("https://raw.githubusercontent.com/Lithe-IDEA/Lithe-store/")); + assert!(problem.is_none()); + let views: Vec = sources.iter().map(source_view).collect(); + assert_eq!(views[0]["deletable"], json!(false)); + assert_eq!(views[0]["kind"], json!("official")); + assert_eq!(views[1]["deletable"], json!(true)); + assert_eq!(views[1]["kind"], json!("thirdParty")); + cleanup(&root); + } + + #[test] + fn add_source_resolves_through_core_with_the_standard_answers() { + let root = unique_temp_root("add"); + let path = sources_path(&root); + let sources = add_source(&path, "https://github.com/example/lithe-plugins") + .expect("github repository resolves"); + assert_eq!(sources.len(), 3); + let added = sources.last().expect("added source"); + assert_eq!(added.id, EXAMPLE_SOURCE_ID); + assert_eq!(added.index_url, EXAMPLE_INDEX_URL); + assert_eq!(added.name, "example/lithe-plugins"); + assert_eq!( + added.repository.as_deref(), + Some("https://github.com/example/lithe-plugins") + ); + assert!(added.is_enabled); + // The added source survives a restart. + let (reloaded, problem) = load_sources(&path).expect("reload"); + assert!(problem.is_none()); + assert!(reloaded.iter().any(|source| source.id == EXAMPLE_SOURCE_ID)); + cleanup(&root); + } + + #[test] + fn add_source_keeps_core_rejection_codes() { + let root = unique_temp_root("invalid"); + let path = sources_path(&root); + let plain_http = add_source(&path, "http://plugins.example.com/index.json") + .expect_err("plain http is refused"); + assert!(plain_http.contains("plugin.source.invalid"), "{plain_http}"); + let empty = add_source(&path, " ").expect_err("empty input is refused"); + assert!( + empty.contains("plugin.marketplace.sources.emptyInput"), + "{empty}" + ); + let (sources, _) = load_sources(&path).expect("nothing was added"); + assert_eq!(sources.len(), 2); + cleanup(&root); + } + + #[test] + fn duplicate_add_is_refused_and_state_is_kept() { + let root = unique_temp_root("duplicate"); + let path = sources_path(&root); + add_source(&path, "https://github.com/example/lithe-plugins").expect("first add"); + set_enabled(&path, EXAMPLE_SOURCE_ID, false).expect("disable"); + // Same index under a different host case, `.git` suffix, surrounding + // whitespace, and a trailing slash: Core normalizes all of it to the + // same index URL, so the host must refuse the duplicate. + let duplicate = add_source(&path, " https://GitHub.com/EXAMPLE/lithe-plugins.git/ ") + .expect_err("duplicate source is refused"); + assert!( + duplicate.contains("plugin.marketplace.sourceAlreadyAdded"), + "{duplicate}" + ); + let (sources, _) = load_sources(&path).expect("reload"); + assert_eq!( + sources.len(), + 3, + "only official, community, and the original entry" + ); + let example = sources + .iter() + .find(|s| s.id == EXAMPLE_SOURCE_ID) + .expect("kept"); + assert!(!example.is_enabled, "the stored enabled state survives"); + cleanup(&root); + } + + #[test] + fn official_source_cannot_be_removed() { + let root = unique_temp_root("official"); + let path = sources_path(&root); + let refused = + remove_source(&path, OFFICIAL_SOURCE_ID).expect_err("official is undeletable"); + assert!( + refused.contains("plugin.marketplace.officialSourceCannotBeRemoved"), + "{refused}" + ); + let (sources, _) = load_sources(&path).expect("reload"); + assert!(sources.iter().any(|source| source.id == OFFICIAL_SOURCE_ID)); + cleanup(&root); + } + + #[test] + fn community_removal_tombstones_across_restarts() { + let root = unique_temp_root("tombstone"); + let path = sources_path(&root); + let remaining = remove_source(&path, COMMUNITY_SOURCE_ID).expect("community is removable"); + assert!(!remaining + .iter() + .any(|source| source.id == COMMUNITY_SOURCE_ID)); + let persisted = std::fs::read_to_string(&path).expect("store file"); + assert!( + persisted.contains(COMMUNITY_SOURCE_ID) && persisted.contains("removedPresetSourceIDs"), + "the removal tombstone is persisted: {persisted}" + ); + // A restart rebuilds from constants: Lithe Store stays removed. + let (sources, _) = load_sources(&path).expect("reload"); + assert!(!sources + .iter() + .any(|source| source.id == COMMUNITY_SOURCE_ID)); + assert!(sources.iter().any(|source| source.id == OFFICIAL_SOURCE_ID)); + cleanup(&root); + } + + #[test] + fn readding_community_restores_the_preset_identity() { + let root = unique_temp_root("readd"); + let path = sources_path(&root); + remove_source(&path, COMMUNITY_SOURCE_ID).expect("remove first"); + let sources = add_source(&path, "https://github.com/Lithe-IDEA/Lithe-store") + .expect("the store URL resolves back to the preset"); + assert_eq!( + sources + .iter() + .filter(|s| s.id == COMMUNITY_SOURCE_ID) + .count(), + 1, + "Lithe Store takes its preset ID back" + ); + let persisted = std::fs::read_to_string(&path).expect("store file"); + let file: Value = serde_json::from_str(&persisted).expect("valid JSON"); + assert!( + file.get("removedPresetSourceIDs").is_none(), + "the tombstone is cleared: {persisted}" + ); + let (reloaded, _) = load_sources(&path).expect("reload"); + assert_eq!( + reloaded + .iter() + .filter(|source| source.id == COMMUNITY_SOURCE_ID) + .count(), + 1, + "no duplicate stand-in after a restart" + ); + cleanup(&root); + } + + #[test] + fn hand_added_store_url_under_different_case_stands_in_for_the_preset() { + let root = unique_temp_root("standin"); + let path = sources_path(&root); + // Lithe Store added by hand before it became a preset, typed with a + // differently cased host: that entry keeps the ID its plugins + // recorded and stands in for the preset. + std::fs::create_dir_all(path.parent().expect("parent")).expect("store dir"); + std::fs::write( + &path, + json!({ + "schemaVersion": 1, + "sources": [{ + "id": "source-legacy1", + "name": "Lithe Store", + "input": "https://RAW.GitHubUserContent.com/Lithe-IDEA/Lithe-store/HEAD/lithe-plugin-index.json", + "indexURL": "https://RAW.GitHubUserContent.com/Lithe-IDEA/Lithe-store/HEAD/lithe-plugin-index.json", + "repository": "https://github.com/Lithe-IDEA/Lithe-store", + "isEnabled": true, + }], + }) + .to_string(), + ) + .expect("store file"); + let (sources, _) = load_sources(&path).expect("reload"); + assert_eq!(sources.len(), 2, "official plus the standing-in entry"); + assert!(sources.iter().any(|source| source.id == "source-legacy1")); + assert!( + !sources + .iter() + .any(|source| source.id == COMMUNITY_SOURCE_ID), + "the preset must not be appended next to its stand-in" + ); + cleanup(&root); + } + + #[test] + fn corrupted_store_is_preserved_and_reported() { + let root = unique_temp_root("corrupt"); + let path = sources_path(&root); + std::fs::create_dir_all(path.parent().expect("parent")).expect("store dir"); + std::fs::write(&path, "{ not json").expect("corrupt store"); + let (sources, problem) = load_sources(&path).expect("rebuild from presets"); + assert!(sources.len() == 2, "presets only"); + let diagnosis = problem.expect("the corruption is diagnosable"); + assert!( + diagnosis.contains("plugin.marketplace.sources.corrupt"), + "{diagnosis}" + ); + let corrupt_copy = path.with_extension("json.corrupt-1"); + assert_eq!( + std::fs::read_to_string(&corrupt_copy).expect("preserved bytes"), + "{ not json", + "the user's data is kept for diagnosis" + ); + assert!(!path.exists(), "the unreadable file moved aside"); + cleanup(&root); + } + + #[test] + fn exhausted_backup_slots_preserve_the_store_and_every_backup() { + let root = unique_temp_root("corrupt-full"); + let path = sources_path(&root); + std::fs::create_dir_all(path.parent().expect("parent")).expect("store dir"); + // Every backup slot holds distinct user data from earlier corruptions. + for attempt in 1..=MAXIMUM_CORRUPTION_BACKUPS { + std::fs::write( + path.with_extension(format!("json.corrupt-{attempt}")), + format!("BACKUP-{attempt}"), + ) + .expect("seed backup"); + } + std::fs::write(&path, "{ current corruption").expect("corrupt store"); + // No free slot remains: the load must fail with a diagnosable error + // instead of deleting any user data. + let failure = load_sources(&path).expect_err("no free backup slot"); + assert!( + failure.contains("plugin.marketplace.sources.corrupt"), + "{failure}" + ); + assert!(failure.contains("backup slots"), "{failure}"); + assert_eq!( + std::fs::read_to_string(&path).expect("original untouched"), + "{ current corruption", + "the unreadable store is never reset or overwritten" + ); + for attempt in 1..=MAXIMUM_CORRUPTION_BACKUPS { + assert_eq!( + std::fs::read_to_string(path.with_extension(format!("json.corrupt-{attempt}"))) + .expect("backup intact"), + format!("BACKUP-{attempt}"), + "backup {attempt} must be byte-identical" + ); + } + cleanup(&root); + } + + #[test] + fn failed_save_cleans_up_its_unique_temp_file() { + let root = unique_temp_root("save-failure"); + let path = sources_path(&root); + std::fs::create_dir_all(&path).expect("the store path is a directory, so rename fails"); + let (sources, _) = + load_sources(&path.parent().unwrap().join("elsewhere.json")).expect("presets"); + let failure = save_sources(&path, &sources).expect_err("renaming onto a directory fails"); + assert!(!failure.is_empty()); + let store_dir = path.parent().expect("parent"); + let leftovers: Vec<_> = std::fs::read_dir(store_dir) + .expect("store dir") + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("sources.json.tmp") + }) + .collect(); + assert!( + leftovers.is_empty(), + "a failed save cleans up its own temp file: {leftovers:?}" + ); + assert!(path.is_dir(), "the failing destination is left as it was"); + cleanup(&root); + } + + #[test] + fn consecutive_corruptions_keep_every_original() { + let root = unique_temp_root("corrupt-chain"); + let path = sources_path(&root); + std::fs::create_dir_all(path.parent().expect("parent")).expect("store dir"); + // Two corruptions in a row must leave both originals on disk. + std::fs::write(&path, "{ first corruption").expect("first store"); + load_sources(&path).expect("first rebuild"); + std::fs::write(&path, "{ second corruption").expect("second store"); + load_sources(&path).expect("second rebuild"); + assert_eq!( + std::fs::read_to_string(path.with_extension("json.corrupt-1")).expect("first backup"), + "{ first corruption", + "the first user data survives the second corruption" + ); + assert_eq!( + std::fs::read_to_string(path.with_extension("json.corrupt-2")).expect("second backup"), + "{ second corruption", + ); + // A pre-existing backup file is never overwritten: the chain moves on. + std::fs::write(path.with_extension("json.corrupt-2"), "USER-DATA-KEPT") + .expect("seed backup"); + std::fs::write(&path, "{ third corruption").expect("third store"); + load_sources(&path).expect("third rebuild"); + assert_eq!( + std::fs::read_to_string(path.with_extension("json.corrupt-2")).expect("seeded backup"), + "USER-DATA-KEPT", + "an existing backup must never be overwritten" + ); + assert_eq!( + std::fs::read_to_string(path.with_extension("json.corrupt-3")).expect("third backup"), + "{ third corruption", + ); + cleanup(&root); + } + + /// Removes the temp root on success AND on failure paths, so a panicking + /// test never leaves a directory behind for a still-running worker. + struct TempRoot(PathBuf); + + impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn concurrent_transactions_do_not_lose_updates() { + let root = TempRoot(unique_temp_root("concurrent")); + let path = sources_path(&root.0); + add_source(&path, "https://github.com/example/lithe-plugins").expect("seed one source"); + // Hold the host lock, then start another window's transaction on a + // separate thread. The worker signals when it is about to enter the + // transaction and again when it has finished; every wait is bounded. + let guard = crate::plugin_host::marketplace_sources::sources_lock() + .lock() + .expect("test holds the lock"); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let spawned_path = path.clone(); + let worker = std::thread::spawn(move || { + started_tx.send(()).expect("receiver alive"); + let result = add_source(&spawned_path, "https://github.com/other/lithe-plugins"); + // The completion signal is the worker's last action, so receiving + // it bounds the whole wait. + done_tx.send(()).expect("receiver alive"); + result + }); + started_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("the worker reached the transaction entry"); + // While the guard is held the transaction cannot complete: the bounded + // receive must time out instead of returning. A broken lock would make + // this return Ok and fail the test. + assert!( + done_rx + .recv_timeout(std::time::Duration::from_millis(1500)) + .is_err(), + "the transaction must wait for the host lock, not run concurrently" + ); + drop(guard); + done_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("the transaction finishes once the lock is released"); + worker + .join() + .expect("worker thread terminates after signaling done"); + let (sources, _) = load_sources(&path).expect("reload"); + assert_eq!( + sources.len(), + 4, + "the seeded source and the concurrent addition both survive" + ); + assert!(sources + .iter() + .any(|source| source.index_url == EXAMPLE_INDEX_URL)); + assert!(sources.iter().any(|source| { + source.index_url + == "https://raw.githubusercontent.com/other/lithe-plugins/HEAD/lithe-plugin-index.json" + })); + } + + #[test] + fn enabled_state_survives_preset_rebuild() { + let root = unique_temp_root("enabled"); + let path = sources_path(&root); + std::fs::create_dir_all(path.parent().expect("parent")).expect("store dir"); + std::fs::write( + &path, + json!({ + "schemaVersion": 1, + "sources": [ + { + "id": OFFICIAL_SOURCE_ID, + "name": "stale-name", + "input": "stale-input", + "indexURL": "stale-url", + "isEnabled": false, + }, + { + "id": COMMUNITY_SOURCE_ID, + "name": "stale-store", + "input": "stale-input", + "indexURL": "stale-url", + "isEnabled": false, + }, + { + "id": EXAMPLE_SOURCE_ID, + "name": "example/lithe-plugins", + "input": "https://github.com/example/lithe-plugins", + "indexURL": EXAMPLE_INDEX_URL, + "repository": "https://github.com/example/lithe-plugins", + "isEnabled": true, + }, + ], + }) + .to_string(), + ) + .expect("store file"); + let (sources, _) = load_sources(&path).expect("reload"); + let official = sources + .iter() + .find(|s| s.id == OFFICIAL_SOURCE_ID) + .expect("official"); + assert_eq!( + official.index_url, OFFICIAL_INDEX_URL, + "preset constants are rebuilt" + ); + assert_eq!( + official.name, "Lithe", + "the stored name never replaces the preset identity" + ); + assert!(!official.is_enabled, "only the enable state is the user's"); + let community = sources + .iter() + .find(|source| source.id == COMMUNITY_SOURCE_ID) + .expect("community"); + assert_eq!(community.index_url, COMMUNITY_INDEX_URL); + assert!(!community.is_enabled); + let example = sources + .iter() + .find(|s| s.id == EXAMPLE_SOURCE_ID) + .expect("kept"); + assert_eq!( + example.name, "example/lithe-plugins", + "user sources are kept verbatim" + ); + assert!(example.is_enabled); + cleanup(&root); + } + + #[test] + fn toggles_persist_atomically_without_temp_files() { + let root = unique_temp_root("toggle"); + let path = sources_path(&root); + set_enabled(&path, OFFICIAL_SOURCE_ID, false).expect("disable official"); + let persisted = std::fs::read_to_string(&path).expect("store file"); + assert!( + persisted.contains("\"isEnabled\": false"), + "the toggle is persisted: {persisted}" + ); + let store_dir = path.parent().expect("parent"); + let leftovers: Vec<_> = std::fs::read_dir(store_dir) + .expect("store dir") + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("sources.json.tmp") + }) + .collect(); + assert!(leftovers.is_empty(), "no temp files remain: {leftovers:?}"); + let (reloaded, _) = load_sources(&path).expect("reload"); + let official = reloaded + .iter() + .find(|s| s.id == OFFICIAL_SOURCE_ID) + .expect("official"); + assert!(!official.is_enabled, "the disable survives a restart"); + assert!( + reloaded + .iter() + .any(|source| source.id == COMMUNITY_SOURCE_ID), + "official removal is impossible, so no tombstone exists" + ); + cleanup(&root); + } + + #[test] + fn save_sources_round_trips_through_the_persisted_shape() { + let root = unique_temp_root("roundtrip"); + let path = sources_path(&root); + let (sources, _) = load_sources(&path).expect("defaults"); + save_sources(&path, &sources).expect("save"); + let persisted = std::fs::read_to_string(&path).expect("store file"); + let file: Value = serde_json::from_str(&persisted).expect("valid JSON"); + assert_eq!(file["schemaVersion"], json!(1)); + let saved = file["sources"].as_array().expect("sources array"); + assert_eq!(saved.len(), 2); + assert_eq!(saved[0]["indexURL"], json!(OFFICIAL_INDEX_URL)); + assert_eq!(saved[1]["isEnabled"], json!(true)); + assert!( + file.get("removedPresetSourceIDs").is_none(), + "nothing removed yet, so the key is omitted like macOS" + ); + cleanup(&root); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/marketplace_tests.rs b/windows/tauri/src-tauri/src/plugin_host/marketplace_tests.rs new file mode 100644 index 0000000000..4d97541bbe --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/marketplace_tests.rs @@ -0,0 +1,1173 @@ +//! M2-C native coordinator regressions. The transport uses a controlled local +//! HTTP server while Core sees real HTTPS release URLs from the shared fixture +//! shape. No external service, plugin process, UI or installed developer tool is needed. + +use super::install; +use super::marketplace_coordinator::{MarketplaceCoordinator, MarketplaceHost}; +use super::marketplace_download::tests::{PauseAt, TestHttpServer, TestResponse, TestRoot}; +use super::marketplace_download::{ + CancelToken, FetchFailure, FetchPolicy, HttpResponse, HttpTransport, ReqwestTransport, +}; +use super::marketplace_sources::MarketplaceSource; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::future::Future; +use std::io::{Cursor, Write}; +use std::path::Path; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +const ID: &str = "io.example.acme-php"; +const INDEX_A: &str = "https://raw.githubusercontent.com/example/market-a/HEAD/index.json"; +const INDEX_B: &str = "https://raw.githubusercontent.com/example/market-b/HEAD/index.json"; +const ARCHIVE_ONE: &str = "/example/lithe-fixture/releases/download/v1.0.0/fixture.zip"; +const ARCHIVE_TWO: &str = "/example/lithe-fixture/releases/download/v1.1.0/fixture.zip"; + +#[tokio::test] +async fn cancel_arriving_before_prepare_registration_prevents_any_network_or_snapshot() { + let harness = Harness::new(vec![]); + harness.coordinator.cancel("main", "cancelled-before-start"); + assert!(harness + .coordinator + .prepare("main", "cancelled-before-start", "source-a", ID) + .await + .is_err()); + assert!(harness + .coordinator + .pending_plugin("main", "cancelled-before-start") + .is_err()); + harness.assert_no_pending_files(); + assert!(!harness.root.0.join("indexes").exists()); + assert!(!harness.root.0.join("plugins").exists()); +} + +#[tokio::test] +async fn an_existing_directory_without_an_installation_record_cannot_be_adopted() { + let bytes = archive("1.0.0", false); + let harness = Harness::new(vec![( + "/example/market-a/HEAD/index.json", + vec![TestResponse::ok(index("1.0.0", &bytes, true, false))], + )]); + let unknown = harness + .root + .0 + .join("plugins") + .join(ID) + .join("legacy-version"); + std::fs::create_dir_all(&unknown).expect("legacy directory"); + std::fs::write(unknown.join("keep.txt"), b"existing bytes").expect("legacy bytes"); + harness.refresh().await; + assert!(harness + .coordinator + .prepare("main", "no-provenance", "source-a", ID) + .await + .is_err()); + assert_eq!( + std::fs::read(unknown.join("keep.txt")).expect("kept bytes"), + b"existing bytes" + ); + assert!(!harness + .root + .0 + .join("plugins") + .join(ID) + .join("installation.json") + .exists()); + harness.assert_no_pending_files(); +} + +#[tokio::test] +async fn a_duplicate_confirmation_does_not_cancel_the_review_already_owned_by_a_worker() { + let bytes = archive("1.0.0", false); + let harness = Harness::new(vec![ + ( + "/example/market-a/HEAD/index.json", + vec![TestResponse::ok(index("1.0.0", &bytes, true, false))], + ), + (ARCHIVE_ONE, vec![TestResponse::ok(bytes)]), + ]); + harness.refresh().await; + let prepared = harness + .coordinator + .prepare("main", "once", "source-a", ID) + .await + .expect("prepare"); + let owner = harness + .coordinator + .claim_confirmation("main", "once") + .expect("claim"); + assert!(harness + .coordinator + .claim_confirmation("main", "once") + .is_err()); + owner + .publish(&prepared.prepared.manifest_digest, true) + .expect("the first confirmation keeps its ownership"); + assert_eq!(harness.record()["version"], "1.0.0"); + harness.assert_no_pending_files(); +} + +#[tokio::test] +async fn a_cancelled_operation_id_cannot_be_revived_and_old_cleanup_keeps_a_new_review() { + let bytes = archive("1.0.0", false); + let harness = Harness::new(vec![ + ( + "/example/market-a/HEAD/index.json", + vec![TestResponse::ok(index("1.0.0", &bytes, true, false))], + ), + (ARCHIVE_ONE, vec![TestResponse::ok(bytes)]), + ]); + harness.refresh().await; + harness + .coordinator + .prepare("main", "reused", "source-a", ID) + .await + .expect("prepare old"); + let old_owner = harness + .coordinator + .claim_confirmation("main", "reused") + .expect("claim old"); + harness.coordinator.cancel("main", "reused"); + harness.assert_no_pending_files(); + assert!(harness + .coordinator + .prepare("main", "reused", "source-a", ID) + .await + .is_err()); + let fresh = harness + .coordinator + .prepare("main", "new-request", "source-a", ID) + .await + .expect("prepare new"); + drop(old_owner); + harness + .coordinator + .confirm("main", "new-request", &fresh.prepared.manifest_digest, true) + .expect("new review remains owned"); + harness.assert_no_pending_files(); +} + +#[tokio::test] +async fn dropping_confirmation_ownership_before_its_barrier_finishes_discards_the_snapshot() { + let bytes = archive("1.0.0", false); + let harness = Harness::new(vec![ + ( + "/example/market-a/HEAD/index.json", + vec![TestResponse::ok(index("1.0.0", &bytes, true, false))], + ), + (ARCHIVE_ONE, vec![TestResponse::ok(bytes)]), + ]); + harness.refresh().await; + harness + .coordinator + .prepare("main", "abandoned", "source-a", ID) + .await + .expect("prepare"); + let owner = harness + .coordinator + .claim_confirmation("main", "abandoned") + .expect("claim"); + drop(owner); + assert!(harness + .coordinator + .pending_plugin("main", "abandoned") + .is_err()); + harness.assert_no_pending_files(); + assert!(!harness.root.0.join("plugins").exists()); +} + +#[tokio::test] +async fn a_window_closed_before_operation_registration_cannot_leave_a_pending_review() { + let harness = Harness::new(vec![]); + harness.coordinator.cancel_window("main"); + harness + .host + .closed_windows + .lock() + .expect("window ownership") + .insert("main".into()); + assert!(harness + .coordinator + .prepare("main", "late-window", "source-a", ID) + .await + .is_err()); + assert!(harness + .coordinator + .pending_plugin("main", "late-window") + .is_err()); + harness.assert_no_pending_files(); +} + +#[tokio::test] +async fn cancellation_after_staging_before_activation_preserves_the_previous_installation() { + let bytes = archive("1.0.0", false); + let harness = Harness::new(vec![ + ( + "/example/market-a/HEAD/index.json", + vec![TestResponse::ok(index("1.0.0", &bytes, true, false))], + ), + (ARCHIVE_ONE, vec![TestResponse::ok(bytes)]), + ]); + harness.refresh().await; + let first = harness + .coordinator + .prepare("main", "first", "source-a", ID) + .await + .expect("prepare"); + harness + .coordinator + .confirm("main", "first", &first.prepared.manifest_digest, true) + .expect("install"); + let original = harness.record(); + let pending = harness + .coordinator + .prepare("main", "cancel-at-commit", "source-a", ID) + .await + .expect("update"); + let weak = Arc::downgrade(&harness.coordinator); + *harness.host.before_commit.lock().expect("commit action") = Some(Box::new(move || { + weak.upgrade() + .expect("coordinator alive") + .cancel("main", "cancel-at-commit"); + })); + assert!(harness + .coordinator + .confirm( + "main", + "cancel-at-commit", + &pending.prepared.manifest_digest, + true + ) + .is_err()); + assert_eq!(harness.record(), original); + assert!(!harness + .root + .0 + .join("plugins") + .join(ID) + .join(".staging-1.0.0") + .exists()); + harness.assert_no_pending_files(); +} + +#[tokio::test] +async fn refreshing_a_source_invalidates_an_older_prepared_selection() { + let bytes = archive("1.0.0", false); + let harness = Harness::new(vec![ + ( + "/example/market-a/HEAD/index.json", + vec![TestResponse::ok(index("1.0.0", &bytes, true, false))], + ), + (ARCHIVE_ONE, vec![TestResponse::ok(bytes)]), + ]); + harness.refresh().await; + let prepared = harness + .coordinator + .prepare("main", "old-selection", "source-a", ID) + .await + .expect("prepare"); + harness.refresh().await; + assert!(harness + .coordinator + .confirm( + "main", + "old-selection", + &prepared.prepared.manifest_digest, + true + ) + .is_err()); + harness.assert_no_pending_files(); + assert!(!harness.root.0.join("plugins").exists()); +} + +#[test] +fn official_identity_requires_both_the_pinned_source_and_a_valid_publisher_signature() { + use super::marketplace_sources::{OFFICIAL_INDEX_URL, OFFICIAL_SOURCE_ID}; + use super::signature; + use base64::Engine; + use ed25519_dalek::Signer; + let signing = signature::rfc8032_signing_key_for_tests(); + signature::with_trusted_keys_for_tests( + std::collections::BTreeMap::from([("lithe-test-v1".into(), signing.verifying_key())]), + || { + let root = TestRoot::new("marketplace-signed"); + let plain = root.0.join("plain.zip"); + std::fs::write(&plain, archive("1.0.0", false)).expect("plain archive"); + let package = root.0.join("package"); + install::extract_zip_snapshot(&plain, &package).expect("snapshot"); + let mut manifest: Value = serde_json::from_slice( + &std::fs::read(package.join("plugin.json")).expect("manifest"), + ) + .expect("JSON"); + manifest["vendor"]["signatureRequirement"] = json!("publisherPackage"); + std::fs::write( + package.join("plugin.json"), + serde_json::to_vec(&manifest).expect("manifest bytes"), + ) + .expect("manifest write"); + let files = signature::file_digests(&package).expect("file digests"); + let signature_bytes = signing.sign(&signature::canonical_payload( + "lithe-test-v1", + ID, + "1.0.0", + &files, + )); + let document = signature::SignatureDocument { + schema_version: 1, + algorithm: "ed25519".into(), + key_id: "lithe-test-v1".into(), + plugin_id: ID.into(), + plugin_version: "1.0.0".into(), + files, + signature: base64::engine::general_purpose::STANDARD + .encode(signature_bytes.to_bytes()), + }; + std::fs::write( + package.join(signature::SIGNATURE_FILE_NAME), + serde_json::to_vec(&document).expect("signature JSON"), + ) + .expect("signature write"); + let zip_package = |target: &Path| { + let mut zip = zip::ZipWriter::new(std::fs::File::create(target).expect("zip")); + for name in [ + "plugin.json", + "language-server/bin/intelephense", + signature::SIGNATURE_FILE_NAME, + ] { + zip.start_file(name, zip::write::SimpleFileOptions::default()) + .expect("file"); + zip.write_all(&std::fs::read(package.join(name)).expect("bytes")) + .expect("write"); + } + zip.finish().expect("zip closes"); + }; + let signed = root.0.join("signed.zip"); + zip_package(&signed); + let official = source(OFFICIAL_SOURCE_ID, OFFICIAL_INDEX_URL); + assert!(super::marketplace_coordinator::official_source(&official)); + assert!(!super::marketplace_coordinator::official_source(&source( + OFFICIAL_SOURCE_ID, + INDEX_A + ))); + let mut selection = install::MarketplaceInstallSource { + source_id: official.id, + source_name: official.name, + index_url: official.index_url, + repository: "https://github.com/example/lithe-fixture".into(), + archive_url: format!("https://github.com{ARCHIVE_ONE}"), + archive_sha256: format!( + "{:x}", + Sha256::digest(std::fs::read(&signed).expect("archive bytes")) + ), + plugin_id: ID.into(), + version: "1.0.0".into(), + official: true, + }; + let temporary = root.0.join("plugin-temp"); + let prepared = install::prepare_marketplace_install_snapshot( + &root.0.join("plugins"), + &temporary, + "lithe-plugin-disk-signed", + &signed, + &selection, + ) + .expect("verified publisher"); + let candidate = install::confirm_marketplace_install_snapshot( + &root.0.join("plugins"), + &temporary, + &prepared.session_id, + &prepared.manifest_digest, + &selection, + ) + .expect("signed confirmation"); + assert_eq!( + candidate.record.origin, + Some(signature::InstallOrigin::Marketplace) + ); + // A valid archive hash cannot bless a package with a broken signature. + std::fs::write( + package.join("language-server/bin/intelephense"), + b"tampered", + ) + .expect("tamper"); + zip_package(&signed); + selection.archive_sha256 = format!( + "{:x}", + Sha256::digest(std::fs::read(&signed).expect("bad archive")) + ); + assert!(install::prepare_marketplace_install_snapshot( + &root.0.join("plugins"), + &temporary, + "lithe-plugin-disk-bad-signature", + &signed, + &selection + ) + .is_err()); + }, + ); +} + +#[tokio::test] +async fn a_changed_snapshot_is_refused_and_its_session_is_discarded() { + let bytes = archive("1.0.0", false); + let harness = Harness::new(vec![ + ( + "/example/market-a/HEAD/index.json", + vec![TestResponse::ok(index("1.0.0", &bytes, true, false))], + ), + (ARCHIVE_ONE, vec![TestResponse::ok(bytes)]), + ]); + harness.refresh().await; + let prepared = harness + .coordinator + .prepare("main", "tamper", "source-a", ID) + .await + .expect("prepare"); + let snapshot = harness + .root + .0 + .join("plugin-temp") + .join(&prepared.prepared.session_id); + std::fs::write( + snapshot.join("language-server/bin/intelephense"), + b"changed", + ) + .expect("mutate snapshot"); + assert!(harness + .coordinator + .confirm("main", "tamper", &prepared.prepared.manifest_digest, true) + .is_err()); + harness.assert_no_pending_files(); + assert!(!harness.root.0.join("plugins").exists()); +} + +#[tokio::test] +async fn changing_installation_approval_after_prepare_requires_a_new_review() { + let bytes = archive("1.0.0", false); + let harness = Harness::new(vec![ + ( + "/example/market-a/HEAD/index.json", + vec![TestResponse::ok(index("1.0.0", &bytes, true, false))], + ), + (ARCHIVE_ONE, vec![TestResponse::ok(bytes)]), + ]); + harness.refresh().await; + let prepared = harness + .coordinator + .prepare("main", "first", "source-a", ID) + .await + .expect("prepare"); + harness + .coordinator + .confirm("main", "first", &prepared.prepared.manifest_digest, true) + .expect("install"); + let pending = harness + .coordinator + .prepare("main", "pending", "source-a", ID) + .await + .expect("update review"); + let mut changed = harness.record(); + changed["enabled"] = json!(false); + let path = harness + .root + .0 + .join("plugins") + .join(ID) + .join("installation.json"); + std::fs::write(&path, serde_json::to_vec(&changed).expect("record bytes")) + .expect("change record"); + assert!(harness + .coordinator + .confirm("main", "pending", &pending.prepared.manifest_digest, true) + .is_err()); + assert_eq!(harness.record(), changed); + let fresh = harness + .coordinator + .prepare("main", "fresh", "source-a", ID) + .await + .expect("new review"); + harness + .coordinator + .confirm("main", "fresh", &fresh.prepared.manifest_digest, true) + .expect("explicit approval of the fresh snapshot"); + assert_eq!( + harness.record()["enabled"], + false, + "marketplace updates preserve a disabled installation" + ); + harness.assert_no_pending_files(); +} + +#[tokio::test] +async fn removing_a_source_cancels_a_refresh_that_is_waiting_for_headers() { + let bytes = archive("1.0.0", false); + let (response, gate) = + TestResponse::paused(index("1.0.0", &bytes, true, false), PauseAt::Headers); + let harness = Harness::new(vec![("/example/market-a/HEAD/index.json", vec![response])]); + let refresh = harness.coordinator.refresh("main", "pending", "source-a"); + tokio::pin!(refresh); + tokio::select! { + result = &mut refresh => panic!("index must be gated: {}", result.is_ok()), + _ = gate.entered() => {} + } + harness + .host + .sources + .lock() + .expect("sources") + .retain(|source| source.id != "source-a"); + harness.coordinator.cancel_source("source-a"); + assert!(tokio::time::timeout(Duration::from_secs(2), &mut refresh) + .await + .expect("cancel stops refresh") + .is_err()); + assert!(!harness + .root + .0 + .join("indexes/source-a.windows-x64.json") + .exists()); +} + +struct TestHost { + sources: Mutex>, + closed_windows: Mutex>, + before_commit: Mutex>>, +} +impl MarketplaceHost for TestHost { + fn sources(&self) -> Result, String> { + Ok(self.sources.lock().expect("sources").clone()) + } + fn platform(&self) -> String { + "windows-x64".into() + } + fn capabilities(&self) -> Value { + super::host_capabilities() + } + fn ensure_window(&self, window: &str) -> Result<(), String> { + if self + .closed_windows + .lock() + .expect("window ownership") + .contains(window) + { + return Err("the marketplace window was closed".into()); + } + Ok(()) + } + fn ensure_installable(&self, _id: &str) -> Result<(), String> { + Ok(()) + } + fn with_source( + &self, + source: &MarketplaceSource, + publish: &mut dyn FnMut() -> Result<(), String>, + ) -> Result<(), String> { + let sources = self.sources.lock().expect("sources"); + if !sources + .iter() + .any(|current| current == source && current.is_enabled) + { + return Err("source changed".into()); + } + if let Some(action) = self.before_commit.lock().expect("commit action").take() { + action(); + } + publish() + } +} + +/// Only test routing changes: the actual fetch policy, body budget, cancellation, +/// deadlines, digest verification and Core validation are production code. +struct LocalTransport { + inner: ReqwestTransport, + base: String, +} +impl HttpTransport for LocalTransport { + fn get<'a>( + &'a self, + url: &'a str, + max_bytes: u64, + deadline: Instant, + cancel: &'a CancelToken, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let parsed = + url::Url::parse(url).map_err(|error| FetchFailure::Transport(error.to_string()))?; + let local = format!("{}{}", self.base, parsed.path()); + self.inner.get(&local, max_bytes, deadline, cancel).await + }) + } +} + +fn fixture(name: &str) -> Value { + let root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../shared/contracts/plugins/v1/fixtures"); + serde_json::from_slice(&std::fs::read(root.join(name)).expect("fixture bytes")) + .expect("fixture JSON") +} + +fn archive(version: &str, changed_program: bool) -> Vec { + archive_with_permissions(version, changed_program, &[]) +} + +fn archive_with_permissions( + version: &str, + changed_program: bool, + extra_permissions: &[&str], +) -> Vec { + let mut manifest = fixture("language-packaged-server.plugin.json"); + manifest["id"] = json!(ID); + manifest["version"] = json!(version); + manifest["vendor"]["signatureRequirement"] = json!("none"); + if changed_program { + manifest["languageServers"][0]["arguments"] = json!(["--stdio", "--new-program-option"]); + } + manifest["permissions"] + .as_array_mut() + .expect("permission declarations") + .extend(extra_permissions.iter().map(|permission| json!(permission))); + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (name, bytes) in [ + ( + "plugin.json", + serde_json::to_vec(&manifest).expect("manifest"), + ), + ( + "language-server/bin/intelephense", + b"fixture, never executed".to_vec(), + ), + ] { + zip.start_file(name, zip::write::SimpleFileOptions::default()) + .expect("zip file"); + zip.write_all(&bytes).expect("zip bytes"); + } + zip.finish().expect("zip closes").into_inner() +} + +fn index(version: &str, archive: &[u8], windows: bool, bad_digest: bool) -> Vec { + let mut entry = fixture("marketplace/index.json")["plugins"][0].clone(); + entry["id"] = json!(ID); + entry["repository"] = json!("https://github.com/example/lithe-fixture"); + entry["icon"] = json!("https://raw.githubusercontent.com/example/lithe-fixture/main/icon.png"); + entry["versions"] = json!([{ + "version": version, "pluginAPIVersion": 1, "releaseDate": "2026-10-25", + "downloads": [{"platforms": [if windows { "windows-x64" } else { "darwin-arm64" }], + "downloadURL": format!("https://github.com/example/lithe-fixture/releases/download/v{version}/fixture.zip"), + "sha256": if bad_digest { "0".repeat(64) } else { format!("{:x}", Sha256::digest(archive)) }}] + }]); + serde_json::to_vec(&json!({"schemaVersion": 1, "plugins": [entry]})).expect("index") +} + +fn source(id: &str, url: &str) -> MarketplaceSource { + MarketplaceSource { + id: id.into(), + name: id.into(), + input: url.into(), + index_url: url.into(), + repository: Some("https://github.com/example/lithe-fixture".into()), + is_enabled: true, + } +} + +struct Harness { + coordinator: Arc, + host: Arc, + _server: TestHttpServer, + root: TestRoot, +} +impl Harness { + fn new(routes: Vec<(&'static str, Vec)>) -> Self { + let root = TestRoot::new("coordinator"); + let server = TestHttpServer::start(routes); + let transport = LocalTransport { + inner: ReqwestTransport::new().expect("transport"), + base: server.base_url.clone(), + }; + let host = Arc::new(TestHost { + sources: Mutex::new(vec![ + source("source-a", INDEX_A), + source("source-b", INDEX_B), + ]), + before_commit: Mutex::new(None), + closed_windows: Mutex::new(std::collections::HashSet::new()), + }); + let coordinator = Arc::new(MarketplaceCoordinator::new( + host.clone(), + Arc::new(transport), + FetchPolicy::https_only(), + root.0.join("indexes"), + root.0.join("plugin-temp"), + root.0.join("plugins"), + )); + Self { + coordinator, + host, + _server: server, + root, + } + } + fn record(&self) -> Value { + serde_json::from_slice( + &std::fs::read( + self.root + .0 + .join("plugins") + .join(ID) + .join("installation.json"), + ) + .expect("record"), + ) + .expect("record JSON") + } + fn installation(&self, source_id: &str) -> Value { + let listed = + serde_json::to_value(self.coordinator.list().expect("market list")).expect("list JSON"); + listed + .as_array() + .expect("sources") + .iter() + .find(|source| source["sourceId"] == source_id) + .expect("selected source")["installations"][ID] + .clone() + } + fn assert_no_pending_files(&self) { + let temporary = self.root.0.join("plugin-temp"); + if temporary.exists() { + assert_eq!( + std::fs::read_dir(temporary) + .expect("temporary root") + .count(), + 0 + ); + } + } + async fn refresh(&self) { + self.coordinator + .refresh("main", "refresh-a", "source-a") + .await + .expect("refresh source a"); + } +} + +#[tokio::test] +async fn refresh_list_prepare_confirm_reuses_m1_and_records_the_exact_source() { + let bytes = archive("1.0.0", false); + let harness = Harness::new(vec![ + ( + "/example/market-a/HEAD/index.json", + vec![TestResponse::ok(index("1.0.0", &bytes, true, false))], + ), + (ARCHIVE_ONE, vec![TestResponse::ok(bytes.clone())]), + ]); + harness.refresh().await; + let listed = harness.coordinator.list().expect("offline list"); + assert!(listed + .iter() + .find(|source| source.source_id == "source-a") + .expect("source") + .index + .is_some()); + let prepared = harness + .coordinator + .prepare("main", "prepare-a", "source-a", ID) + .await + .expect("prepare"); + assert!(prepared.requires_confirmation); + assert!(!prepared.official_publisher_verified); + assert!(!prepared.prepared.review.programs.is_empty()); + assert!(harness + .coordinator + .confirm( + "workspace-other", + "prepare-a", + &prepared.prepared.manifest_digest, + true + ) + .is_err()); + let installed = harness + .coordinator + .confirm( + "main", + "prepare-a", + &prepared.prepared.manifest_digest, + true, + ) + .expect("confirm"); + assert_eq!(installed.plugin_id, ID); + let record = harness.record(); + assert!(record["disk"].is_null()); + assert_eq!(record["marketplace"]["sourceId"], "source-a"); + assert_eq!(record["marketplace"]["indexUrl"], INDEX_A); + assert_eq!( + record["marketplace"]["archiveSha256"], + format!("{:x}", Sha256::digest(bytes)) + ); + assert!(record["marketplace"]["contentDigest"] + .as_str() + .is_some_and(|digest| digest.len() == 64)); + let catalog = super::scan::build_catalog_from( + &harness.root.0.join("bundled"), + &harness.root.0.join("plugins"), + ) + .expect("production scan revalidates provenance"); + assert!(catalog + .iter() + .any(|package| package.entry.plugin_id == ID && package.entry.status == "accepted")); + assert_eq!(harness.installation("source-a")["origin"], "marketplace"); + assert_eq!(harness.installation("source-a")["canPrepare"], true); + harness.assert_no_pending_files(); +} + +#[tokio::test] +async fn same_id_from_another_source_cannot_take_over_an_installation() { + let bytes = archive("1.0.0", false); + let document = index("1.0.0", &bytes, true, false); + let harness = Harness::new(vec![ + ( + "/example/market-a/HEAD/index.json", + vec![TestResponse::ok(document.clone())], + ), + ( + "/example/market-b/HEAD/index.json", + vec![TestResponse::ok(document)], + ), + (ARCHIVE_ONE, vec![TestResponse::ok(bytes)]), + ]); + harness.refresh().await; + let prepared = harness + .coordinator + .prepare("main", "first", "source-a", ID) + .await + .expect("prepare"); + harness + .coordinator + .confirm("main", "first", &prepared.prepared.manifest_digest, true) + .expect("install"); + let original = harness.record(); + harness + .coordinator + .refresh("main", "refresh-b", "source-b") + .await + .expect("refresh b"); + assert_eq!(harness.installation("source-b")["sourceId"], "source-a"); + assert_eq!(harness.installation("source-b")["canPrepare"], false); + assert!(harness + .coordinator + .prepare("main", "wrong-source", "source-b", ID) + .await + .is_err()); + assert_eq!(harness.record(), original); + harness.assert_no_pending_files(); +} + +#[tokio::test] +async fn disk_provenance_cannot_be_adopted_by_a_marketplace_listing() { + let bytes = archive("1.0.0", false); + let harness = Harness::new(vec![( + "/example/market-a/HEAD/index.json", + vec![TestResponse::ok(index("1.0.0", &bytes, true, false))], + )]); + let package = harness.root.0.join("disk.zip"); + std::fs::write(&package, bytes).expect("disk archive"); + let temporary = harness.root.0.join("disk-review"); + let prepared = install::prepare_disk_install_snapshot( + &temporary, + "lithe-plugin-disk-test", + package.to_str().expect("path"), + ) + .expect("disk review"); + let installed = install::confirm_disk_install_snapshot( + &harness.root.0.join("plugins"), + &temporary, + &prepared.session_id, + &prepared.manifest_digest, + ) + .expect("disk confirm"); + install::publish_installed_version( + &harness.root.0.join("plugins"), + ID, + "1.0.0", + &temporary.join(&prepared.session_id), + installed.record, + ) + .expect("disk publish"); + let original = harness.record(); + harness.refresh().await; + assert_eq!(harness.installation("source-a")["origin"], "disk"); + assert_eq!(harness.installation("source-a")["canPrepare"], false); + assert!(harness + .coordinator + .prepare("main", "adopt", "source-a", ID) + .await + .is_err()); + assert_eq!(harness.record(), original); + harness.assert_no_pending_files(); +} + +#[tokio::test] +async fn a_plugin_without_a_windows_archive_cannot_be_prepared() { + let bytes = archive("1.0.0", false); + let harness = Harness::new(vec![( + "/example/market-a/HEAD/index.json", + vec![TestResponse::ok(index("1.0.0", &bytes, false, false))], + )]); + harness.refresh().await; + assert_eq!(harness.installation("source-a")["origin"], "none"); + assert_eq!(harness.installation("source-a")["canPrepare"], false); + assert!(harness + .coordinator + .prepare("main", "no-windows", "source-a", ID) + .await + .is_err()); + harness.assert_no_pending_files(); + assert!(!harness.root.0.join("plugins").exists()); +} + +#[tokio::test] +async fn a_bad_archive_digest_never_reaches_the_install_snapshot() { + let bytes = archive("1.0.0", false); + let harness = Harness::new(vec![ + ( + "/example/market-a/HEAD/index.json", + vec![TestResponse::ok(index("1.0.0", &bytes, true, true))], + ), + (ARCHIVE_ONE, vec![TestResponse::ok(bytes)]), + ]); + harness.refresh().await; + assert!(harness + .coordinator + .prepare("main", "bad-digest", "source-a", ID) + .await + .is_err()); + harness.assert_no_pending_files(); + assert!(!harness.root.0.join("plugins").exists()); +} + +#[tokio::test] +async fn cancelling_an_in_flight_prepare_drops_late_results_and_all_downloads() { + let bytes = archive("1.0.0", false); + let (response, gate) = TestResponse::paused(bytes.clone(), PauseAt::Headers); + let harness = Harness::new(vec![ + ( + "/example/market-a/HEAD/index.json", + vec![TestResponse::ok(index("1.0.0", &bytes, true, false))], + ), + (ARCHIVE_ONE, vec![response]), + ]); + harness.refresh().await; + let prepare = harness + .coordinator + .prepare("main", "pending", "source-a", ID); + tokio::pin!(prepare); + tokio::select! { + result = &mut prepare => panic!("archive must be gated: {}", result.is_ok()), + _ = gate.entered() => {} + } + harness.coordinator.cancel("workspace-other", "pending"); + assert!(harness + .coordinator + .pending_plugin("main", "pending") + .is_ok()); + harness.coordinator.cancel("main", "pending"); + assert!(tokio::time::timeout(Duration::from_secs(2), &mut prepare) + .await + .expect("cancel terminates") + .is_err()); + harness.assert_no_pending_files(); + assert!(!harness.root.0.join("plugins").exists()); +} + +#[tokio::test] +async fn window_close_source_disable_and_plugin_disable_discard_prepared_reviews() { + let bytes = archive("1.0.0", false); + let harness = Harness::new(vec![ + ( + "/example/market-a/HEAD/index.json", + vec![TestResponse::ok(index("1.0.0", &bytes, true, false))], + ), + (ARCHIVE_ONE, vec![TestResponse::ok(bytes)]), + ]); + harness.refresh().await; + for action in ["window", "source", "plugin"] { + let owner = if action == "window" { + "workspace-closing" + } else { + "main" + }; + let prepared = harness + .coordinator + .prepare(owner, action, "source-a", ID) + .await + .expect("prepare"); + match action { + "window" => harness.coordinator.cancel_window(owner), + "source" => harness.coordinator.cancel_source("source-a"), + _ => harness.coordinator.cancel_plugin(ID), + } + assert!(harness + .coordinator + .confirm(owner, action, &prepared.prepared.manifest_digest, true) + .is_err()); + harness.assert_no_pending_files(); + } + assert!(!harness.root.0.join("plugins").exists()); +} + +#[tokio::test] +async fn source_state_is_rechecked_at_confirmation_even_without_a_notification() { + let bytes = archive("1.0.0", false); + let harness = Harness::new(vec![ + ( + "/example/market-a/HEAD/index.json", + vec![TestResponse::ok(index("1.0.0", &bytes, true, false))], + ), + (ARCHIVE_ONE, vec![TestResponse::ok(bytes)]), + ]); + harness.refresh().await; + let prepared = harness + .coordinator + .prepare("main", "disabled", "source-a", ID) + .await + .expect("prepare"); + harness.host.sources.lock().expect("sources")[0].is_enabled = false; + assert!(harness + .coordinator + .confirm("main", "disabled", &prepared.prepared.manifest_digest, true) + .is_err()); + harness.assert_no_pending_files(); + assert!(!harness.root.0.join("plugins").exists()); +} + +#[tokio::test] +async fn every_marketplace_snapshot_requires_explicit_approval_including_unchanged_programs() { + let first = archive("1.0.0", false); + let changed = archive("1.1.0", true); + let harness = Harness::new(vec![ + ( + "/example/market-a/HEAD/index.json", + vec![ + TestResponse::ok(index("1.0.0", &first, true, false)), + TestResponse::ok(index("1.1.0", &changed, true, false)), + ], + ), + (ARCHIVE_ONE, vec![TestResponse::ok(first)]), + (ARCHIVE_TWO, vec![TestResponse::ok(changed)]), + ]); + harness.refresh().await; + let prepared = harness + .coordinator + .prepare("main", "first", "source-a", ID) + .await + .expect("first review"); + harness + .coordinator + .confirm("main", "first", &prepared.prepared.manifest_digest, true) + .expect("first install"); + let repeat = harness + .coordinator + .prepare("main", "repeat", "source-a", ID) + .await + .expect("same programs"); + assert!(repeat.prepared.review.new_programs.is_empty()); + assert!(repeat.requires_confirmation); + assert!(harness + .coordinator + .confirm("main", "repeat", &repeat.prepared.manifest_digest, false) + .is_err()); + let repeat_approved = harness + .coordinator + .prepare("main", "repeat-approved", "source-a", ID) + .await + .expect("fresh explicit review"); + harness + .coordinator + .confirm( + "main", + "repeat-approved", + &repeat_approved.prepared.manifest_digest, + true, + ) + .expect("explicit approval of unchanged programs"); + let original = harness.record(); + harness.refresh().await; + let new = harness + .coordinator + .prepare("main", "new-programs", "source-a", ID) + .await + .expect("changed review"); + assert!(!new.prepared.review.new_programs.is_empty()); + assert!(new.requires_confirmation); + assert!(harness + .coordinator + .confirm("main", "new-programs", &new.prepared.manifest_digest, false) + .is_err()); + assert_eq!(harness.record(), original); + let approved = harness + .coordinator + .prepare("main", "approved", "source-a", ID) + .await + .expect("new review"); + harness + .coordinator + .confirm("main", "approved", &approved.prepared.manifest_digest, true) + .expect("explicit approval"); + assert_eq!(harness.record()["version"], "1.1.0"); + assert_eq!(harness.record()["previousVersion"], "1.0.0"); + harness.assert_no_pending_files(); +} + +#[tokio::test] +async fn permission_only_expansion_cannot_reuse_program_approvals_to_skip_consent() { + // Core's program identity deliberately excludes permission grants. Keeping + // every executable/argument unchanged must not silently approve ui.web. + let first = archive("1.0.0", false); + let expanded = archive_with_permissions("1.1.0", false, &["ui.contribute", "ui.web"]); + let harness = Harness::new(vec![ + ( + "/example/market-a/HEAD/index.json", + vec![ + TestResponse::ok(index("1.0.0", &first, true, false)), + TestResponse::ok(index("1.1.0", &expanded, true, false)), + ], + ), + (ARCHIVE_ONE, vec![TestResponse::ok(first)]), + (ARCHIVE_TWO, vec![TestResponse::ok(expanded)]), + ]); + harness.refresh().await; + let first = harness + .coordinator + .prepare("main", "original", "source-a", ID) + .await + .expect("initial snapshot"); + harness + .coordinator + .confirm("main", "original", &first.prepared.manifest_digest, true) + .expect("initial approval"); + let original = harness.record(); + harness.refresh().await; + let expanded = harness + .coordinator + .prepare("main", "permission-only", "source-a", ID) + .await + .expect("expanded permission snapshot"); + assert!(expanded.prepared.review.new_programs.is_empty()); + assert!(expanded + .prepared + .review + .permissions + .iter() + .any(|permission| permission == "ui.web")); + assert!(expanded.requires_confirmation); + assert!(harness + .coordinator + .confirm( + "main", + "permission-only", + &expanded.prepared.manifest_digest, + false, + ) + .is_err()); + assert_eq!(harness.record(), original); + harness.assert_no_pending_files(); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/mod.rs b/windows/tauri/src-tauri/src/plugin_host/mod.rs index 6ca4a91922..7337b6e202 100644 --- a/windows/tauri/src-tauri/src/plugin_host/mod.rs +++ b/windows/tauri/src-tauri/src/plugin_host/mod.rs @@ -8,17 +8,42 @@ //! capability rollout are recorded in //! .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md +pub mod build; +pub mod build_registry; +pub(crate) mod build_resolver; +#[cfg(test)] +mod build_resolver_tests; +#[cfg(test)] +mod build_tests; +pub mod details; +#[cfg(test)] +mod details_tests; +pub mod functions; pub mod install; #[cfg(test)] mod install_tests; pub mod launch; pub mod lifecycle; +pub mod marketplace; +pub(crate) mod marketplace_coordinator; +pub mod marketplace_download; +pub mod marketplace_index; +pub mod marketplace_sources; +#[cfg(test)] +mod marketplace_sources_tests; +#[cfg(test)] +mod marketplace_tests; +pub mod native_ui; +pub mod project_generators; +pub(crate) mod provider; pub mod run_plans; pub mod scan; pub mod settings; +pub mod signature; pub mod store; #[cfg(test)] mod tests; +pub mod vcs; pub mod web; use serde::Serialize; @@ -108,6 +133,19 @@ pub struct ScannedPackage { pub root: PathBuf, /// Raw manifest; plan commands are pure functions and take it every time. pub manifest: Value, + /// Trust evidence the launch path re-verifies against the live bytes; + /// `None` for quarantined packages, which never reach a launch. + pub trust: Option, +} + +/// Origin and approved-bytes evidence recorded when the package was +/// accepted, so every later launch can prove the on-disk bytes again. +#[derive(Debug, Clone)] +pub struct TrustEvidence { + pub origin: signature::InstallOrigin, + /// Content digest recorded for a community approval; bundled and signed + /// packages verify through the pinned policy instead. + pub expected_content_digest: Option, } /// Cached scan result. The frontend rescans at startup and after installs, diff --git a/windows/tauri/src-tauri/src/plugin_host/native_ui/mod.rs b/windows/tauri/src-tauri/src/plugin_host/native_ui/mod.rs new file mode 100644 index 0000000000..f9ab57c7d7 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/native_ui/mod.rs @@ -0,0 +1,68 @@ +//! Read-only metadata for accepted native declarations. This does not admit ui 3 +//! packages, open capabilities, or execute a plugin function. +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tauri::{AppHandle, Manager}; + +#[tauri::command] +pub async fn declarative_plugins_native_windows(app: AppHandle) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || catalog(&app)) + .await + .map_err(|error| error.to_string())? +} + +pub(crate) fn catalog(app: &AppHandle) -> Result, String> { + let mut windows = Vec::new(); + for candidate in app.state::().packages() { + if candidate.entry.status != "accepted" || !candidate.entry.enabled { + continue; + } + let declarations: Vec<_> = candidate.manifest["ui"]["toolWindows"] + .as_array() + .into_iter() + .flatten() + .filter(|window| window.get("native").is_some()) + .collect(); + if declarations.is_empty() { + continue; + } + let _lease = app + .state::() + .acquire(&candidate.entry.plugin_id)?; + let package = super::launch::verified_package_by_id(app, &candidate.entry.plugin_id)?; + let details = super::details::build_details( + &package, + &super::store::install_root(app)?, + &super::store::bundled_root(app)?, + ) + .map_err(|error| error.message)?; + if !details.enabled { + continue; + } + let digest = super::signature::content_digest(&package.root) + .map_err(|error| error.message().to_string())?; + let package_identity = format!( + "{:x}", + Sha256::digest( + json!({"root":package.root,"source":package.entry.source, + "version":package.entry.version,"digest":digest}) + .to_string() + .as_bytes() + ) + ); + for window in declarations { + windows.push(json!({"pluginId":package.entry.plugin_id, + "packageIdentity":package_identity,"windowId":window["id"], + "title":window["title"],"placement":window["placement"], + "order":window["order"],"native":window["native"]})); + } + } + windows.sort_by_key(|window| { + ( + window["order"].as_u64().unwrap_or(u64::MAX), + window["pluginId"].as_str().unwrap_or_default().to_string(), + window["windowId"].as_str().unwrap_or_default().to_string(), + ) + }); + Ok(windows) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/project_generators/archive.rs b/windows/tauri/src-tauri/src/plugin_host/project_generators/archive.rs new file mode 100644 index 0000000000..ba123fd779 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/project_generators/archive.rs @@ -0,0 +1,262 @@ +//! Consent-bound archive fetches; ZIP interpretation remains in shared Core. +use super::super::marketplace_download::{ + fetch_controlled, CancelToken, FetchFailure, FetchLimits, FetchPolicy, HttpResponse, + HttpTransport, MAX_ARTIFACT_BYTES, MAX_REDIRECT_HOPS, +}; +use super::{Error, Result}; +use serde_json::{json, Value}; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +const DOWNLOAD_BUDGET: Duration = Duration::from_secs(120); + +struct RedirectBudget<'a> { + transport: &'a dyn HttpTransport, + remaining: AtomicU64, +} +impl HttpTransport for RedirectBudget<'_> { + fn get<'a>( + &'a self, + url: &'a str, + max_bytes: u64, + deadline: Instant, + cancel: &'a CancelToken, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'a, + >, + > { + Box::pin(async move { + let remaining = self.remaining.load(Ordering::Acquire).min(max_bytes); + let response = self.transport.get(url, remaining, deadline, cancel).await?; + if response.body.len() as u64 > remaining { + return Err(FetchFailure::BudgetExceeded { limit: max_bytes }); + } + self.remaining + .store(remaining - response.body.len() as u64, Ordering::Release); + Ok(response) + }) + } +} + +/// Runs on the generation worker. Its caller retains staging until every +/// synchronous Core write has returned, including cancellation and timeout. +pub(super) fn execute( + transport: &dyn HttpTransport, + url: &str, + stage: &Path, + operation_id: &str, + cancel: &CancelToken, + deadline: Instant, + checkpoint: &dyn Fn() -> Result<()>, +) -> Result<()> { + let check = || { + cancel.check(deadline).map_err(fetch_error)?; + checkpoint() + }; + check()?; + let transport = RedirectBudget { + transport, + remaining: AtomicU64::new(MAX_ARTIFACT_BYTES), + }; + let downloaded = tauri::async_runtime::block_on(fetch_controlled( + &transport, + url, + FetchLimits { + max_bytes: MAX_ARTIFACT_BYTES, + max_redirects: MAX_REDIRECT_HOPS, + }, + FetchPolicy::https_only(), + cancel, + deadline.min(Instant::now() + DOWNLOAD_BUDGET), + )) + .map_err(fetch_error)?; + // The transaction callback also revalidates staging identity after the + // network wait, before any path-based ZIP creation or Core extraction. + check()?; + // No digest is declared for project archives. The controlled HTTPS body is + // untrusted ZIP input, never a marketplace artifact or approved package. + // Keeping its create_new file inside this private transaction also makes + // failed removal retryable through the original staging owner. + let path = stage.join(format!( + ".lithe-project-archive-{}.zip", + super::super::install::uuid_like_suffix() + )); + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&path) + .map_err(Error::io)?; + let extracted = (|| { + for chunk in downloaded.body.chunks(64 * 1024) { + check()?; + file.write_all(chunk).map_err(Error::io)?; + } + file.sync_all().map_err(Error::io)?; + drop(file); + check()?; + let timeout = deadline + .saturating_duration_since(Instant::now()) + .as_millis() + .min(u64::MAX as u128) + .max(1) as u64; + let request = json!({"command":"project.extractArchive", "operationId":operation_id, + "timeoutMilliseconds":timeout, "payload":{"archivePath":path,"destination":stage}}); + let response: Value = serde_json::from_str(&lithe_core::execute_json(&request.to_string())) + .map_err(|_| Error::new("generator.coreFailed"))?; + if response["ok"] != true { + return Err(Error::new(match response["error"]["code"].as_str() { + Some("cancelled") => "generator.cancelled", + Some("timed_out") => "generator.timeout", + _ => "generator.coreFailed", + })); + } + if response["data"]["status"] != "ok" { + return Err(Error::new("generator.archiveInvalid")); + } + check() + })(); + // The archive descriptor and Core's entry descriptors have closed before + // removal. A failure belongs to the surrounding Transaction's cleanup. + std::fs::remove_file(&path).map_err(Error::io)?; + extracted +} + +fn fetch_error(error: FetchFailure) -> Error { + Error::new(match error { + FetchFailure::Cancelled => "generator.cancelled", + FetchFailure::DeadlineExceeded => "generator.timeout", + FetchFailure::BudgetExceeded { .. } => "generator.outputLimit", + _ => "generator.downloadFailed", + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + struct OversizedRedirect; + impl HttpTransport for OversizedRedirect { + fn get<'a>( + &'a self, + url: &'a str, + _: u64, + _: Instant, + _: &'a CancelToken, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'a, + >, + > { + Box::pin(async move { + Ok(if url.ends_with("first") { + HttpResponse { + status: 302, + headers: HashMap::from([("location".into(), "/second".into())]), + body: vec![0; 6], + } + } else { + HttpResponse { + status: 200, + headers: HashMap::new(), + body: vec![0; 6], + } + }) + }) + } + } + #[tokio::test] + async fn redirects_share_one_total_download_byte_budget() { + let transport = RedirectBudget { + transport: &OversizedRedirect, + remaining: AtomicU64::new(10), + }; + let result = fetch_controlled( + &transport, + "https://fixture.invalid/first", + FetchLimits { + max_bytes: 10, + max_redirects: 5, + }, + FetchPolicy::https_only(), + &CancelToken::new(), + Instant::now() + Duration::from_secs(2), + ) + .await; + assert!(matches!(result, Err(FetchFailure::BudgetExceeded { .. }))); + } + + struct ExactRedirect { + calls: std::sync::Mutex>, + } + impl HttpTransport for ExactRedirect { + fn get<'a>( + &'a self, + url: &'a str, + max_bytes: u64, + deadline: Instant, + _: &'a CancelToken, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'a, + >, + > { + self.calls.lock().unwrap().push((max_bytes, deadline)); + Box::pin(async move { + Ok(if url.ends_with("first") { + HttpResponse { + status: 302, + headers: HashMap::from([("location".into(), "/second".into())]), + body: vec![0; 6], + } + } else { + HttpResponse { + status: 200, + headers: HashMap::new(), + body: vec![0; 4], + } + }) + }) + } + } + + #[tokio::test] + async fn exact_total_download_budget_preserves_the_original_absolute_deadline() { + let fixture = ExactRedirect { + calls: std::sync::Mutex::new(Vec::new()), + }; + let transport = RedirectBudget { + transport: &fixture, + remaining: AtomicU64::new(10), + }; + let deadline = Instant::now() + Duration::from_secs(2); + let result = fetch_controlled( + &transport, + "https://fixture.invalid/first", + FetchLimits { + max_bytes: 10, + max_redirects: 5, + }, + FetchPolicy::https_only(), + &CancelToken::new(), + deadline, + ) + .await + .unwrap(); + assert_eq!(result.body.len(), 4); + assert_eq!(result.hops, 1); + assert_eq!( + fixture.calls.lock().unwrap().as_slice(), + &[(10, deadline), (4, deadline)] + ); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/project_generators/catalog.rs b/windows/tauri/src-tauri/src/plugin_host/project_generators/catalog.rs new file mode 100644 index 0000000000..52d92822f0 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/project_generators/catalog.rs @@ -0,0 +1,322 @@ +//! Read-only generator metadata, including disabled accepted packages. Trust +//! and installation-record validation stay in the existing details reader. +use super::{core, Error, Result}; +use crate::plugin_host::{self, lifecycle::LifecycleState, PluginHostState, ScannedPackage}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use tauri::{AppHandle, Manager}; + +fn identity(root: &std::path::Path, source: &str, version: &str, digest: &str) -> String { + format!( + "{:x}", + Sha256::digest( + json!({"root":root,"source":source, + "version":version,"digest":digest}) + .to_string() + .as_bytes() + ) + ) +} + +/// Caller owns a NativeLease. Reading disabled metadata grants no execution +/// permission; plan/runtime entry points retain their enabled-package check. +pub(super) fn read(app: &AppHandle, plugin_id: &str) -> Result<(ScannedPackage, String)> { + if app.state::().blocked(plugin_id) { + return Err(Error::new("generator.inactive")); + } + let mut package = app + .state::() + .packages() + .into_iter() + .find(|package| package.entry.plugin_id == plugin_id && package.entry.status == "accepted") + .ok_or_else(|| Error::new("generator.inactive"))?; + let details = plugin_host::details::build_details( + &package, + &plugin_host::store::install_root(app).map_err(|_| Error::new("generator.untrusted"))?, + &plugin_host::store::bundled_root(app).map_err(|_| Error::new("generator.untrusted"))?, + ) + .map_err(|_| Error::new("generator.untrusted"))?; + let live: Value = serde_json::from_slice( + &std::fs::read(package.root.join("plugin.json")).map_err(Error::io)?, + ) + .map_err(|_| Error::new("generator.stale"))?; + if live != package.manifest { + return Err(Error::new("generator.stale")); + } + package.entry.enabled = details.enabled; + let digest = plugin_host::signature::content_digest(&package.root) + .map_err(|_| Error::new("generator.untrusted"))?; + let identity = identity( + &package.root, + package.entry.source, + &package.entry.version, + &digest, + ); + Ok((package, identity)) +} + +pub(super) fn load(app: &AppHandle) -> Result { + load_required(app, &HashSet::new()) +} + +fn load_required(app: &AppHandle, required: &HashSet) -> Result { + load_packages( + app.state::().packages(), + required, + plugin_host::host_capabilities(), + |plugin_id| { + let lease = app + .state::() + .acquire(plugin_id) + .map_err(|_| Error::new("generator.inactive"))?; + let (package, identity) = read(app, plugin_id)?; + Ok((package, identity, lease)) + }, + ) +} + +fn load_packages( + packages: Vec, + required: &HashSet, + capabilities: Value, + mut read: impl FnMut(&str) -> Result<(ScannedPackage, String, Lease)>, +) -> Result { + let mut plugins = Vec::new(); + let mut identities = std::collections::HashMap::new(); + let mut leases = Vec::new(); + let mut issues = Vec::new(); + for package in packages + .into_iter() + .filter(|package| package.entry.status == "accepted") + { + let (package, identity, lease) = match read(&package.entry.plugin_id) { + Ok(package) => package, + Err(error) if required.contains(&package.entry.plugin_id) => return Err(error), + // One retained mutation or unreadable unrelated package must not + // prevent another generator from being inspected or reviewed. + Err(error) => { + issues.push(json!({"pluginID":package.entry.plugin_id, + "error":{"code":error.code}})); + continue; + } + }; + leases.push(lease); + plugins.push(json!({"manifest":package.manifest,"enabled":package.entry.enabled})); + identities.insert( + package.entry.plugin_id, + (identity, package.entry.source == "installed"), + ); + } + if required + .iter() + .any(|plugin_id| !identities.contains_key(plugin_id)) + { + return Err(Error::new("generator.inactive")); + } + let mut catalog = core( + "project.generatorCatalog", + json!({"plugins":plugins,"hostCapabilities":capabilities}), + "catalog", + )?; + if let Some(rejections) = catalog["issues"].as_array_mut() { + rejections.extend(issues); + } + for entry in catalog["generators"].as_array_mut().into_iter().flatten() { + let (identity, can_enable) = identities + .get(entry["pluginID"].as_str().unwrap_or_default()) + .ok_or_else(|| Error::new("generator.stale"))?; + entry["packageIdentity"] = json!(identity); + entry["canEnable"] = json!(can_enable); + for requirement in entry["requirements"].as_array_mut().into_iter().flatten() { + for provider in requirement["providers"] + .as_array_mut() + .into_iter() + .flatten() + { + let (identity, can_enable) = identities + .get(provider["pluginID"].as_str().unwrap_or_default()) + .ok_or_else(|| Error::new("generator.stale"))?; + provider["packageIdentity"] = json!(identity); + provider["canEnable"] = json!(can_enable); + } + } + } + Ok(catalog) +} + +/// Advisory requirements are re-read before both review and execution. A +/// changed provider snapshot requires fresh review, never auto-enablement. +pub(super) fn requirements( + app: &AppHandle, + request: &super::Request, + package: &ScannedPackage, +) -> Result { + let form = core( + "project.generatorForm", + json!({"manifest":package.manifest, + "generatorID":request.generator_id,"form":request.form}), + "form", + )?; + // Requirements are advisory, including multiple interchangeable providers. + // Only the generator owner is mandatory. Core projects the live readable + // providers; a disappearance changes the review snapshot below, without + // preventing a new review merely because one alternative is unavailable. + let catalog = load_required(app, &HashSet::from([request.plugin_id.clone()]))?; + let entry = catalog["generators"] + .as_array() + .into_iter() + .flatten() + .find(|entry| { + entry["pluginID"] == request.plugin_id + && entry["generator"]["id"] == request.generator_id + }) + .ok_or_else(|| Error::new("generator.inactive"))?; + Ok(json!(form["activeRequirements"] + .as_array() + .into_iter() + .flatten() + .filter_map(|index| { entry["requirements"].get(index.as_u64()? as usize).cloned() }) + .collect::>())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn package(id: &str) -> ScannedPackage { + let mut manifest: Value = serde_json::from_str(include_str!( + "../../../../../../shared/contracts/plugins/v1/fixtures/project-generator.plugin.json" + )) + .unwrap(); + manifest["id"] = json!(id); + ScannedPackage { + entry: plugin_host::CatalogEntry { + plugin_id: id.into(), + display_name: id.into(), + version: "1.0.0".into(), + api_version: 3, + source: "installed", + enabled: true, + status: "accepted", + languages: Vec::new(), + declared_settings: Vec::new(), + declares_tests: false, + primary_language_id: None, + rejection: None, + }, + root: std::path::PathBuf::from("fixture-package"), + manifest, + trust: None, + } + } + + #[test] + fn unreadable_unrelated_packages_do_not_disable_another_generator() { + for code in ["generator.inactive", "generator.untrusted"] { + let target = package("fixture.generator"); + let unrelated = package("fixture.other"); + let catalog = load_packages( + vec![target.clone(), unrelated], + &HashSet::from([target.entry.plugin_id.clone()]), + json!({"projectGenerators":[1]}), + |id| { + if id == target.entry.plugin_id { + Ok((target.clone(), "target-digest".into(), ())) + } else { + Err(Error::new(code)) + } + }, + ) + .unwrap(); + assert!(!catalog["generators"].as_array().unwrap().is_empty()); + assert!(catalog["generators"] + .as_array() + .unwrap() + .iter() + .all(|entry| entry["pluginID"] == "fixture.generator")); + assert_eq!(catalog["issues"][0]["error"]["code"], code); + } + } + + #[test] + fn unreadable_or_missing_generator_owner_remains_fail_closed() { + let target = package("fixture.generator"); + for packages in [vec![target.clone()], Vec::new()] { + let result = load_packages::<()>( + packages, + &HashSet::from([target.entry.plugin_id.clone()]), + json!({"projectGenerators":[1]}), + |_| Err(Error::new("generator.inactive")), + ); + assert_eq!(result.unwrap_err().code, "generator.inactive"); + } + } + + #[test] + fn unavailable_advisory_alternatives_change_the_snapshot_without_blocking_a_new_review() { + let target = package("fixture.generator"); + let mut provider = package("fixture.java"); + provider.manifest = serde_json::from_str(include_str!( + "../../../../../../shared/contracts/plugins/v1/fixtures/node-runtime.plugin.json" + )) + .unwrap(); + provider.manifest["id"] = json!(provider.entry.plugin_id); + provider.manifest["languages"][0]["id"] = json!("java"); + provider.manifest["languageServers"][0]["languageID"] = json!("java"); + provider.manifest["activationEvents"] = json!(["onLanguage:java"]); + let mut alternative = provider.clone(); + alternative.entry.plugin_id = "fixture.other-java".into(); + alternative.entry.enabled = false; + alternative.manifest["id"] = json!(alternative.entry.plugin_id); + let packages = vec![target.clone(), provider.clone(), alternative.clone()]; + let snapshot = |unavailable: &[&str]| { + let catalog = load_packages( + packages.clone(), + &HashSet::from([target.entry.plugin_id.clone()]), + json!({"projectGenerators":[1]}), + |id| { + if unavailable.contains(&id) { + Err(Error::new("generator.inactive")) + } else { + let package = packages + .iter() + .find(|package| package.entry.plugin_id == id) + .unwrap() + .clone(); + Ok((package, format!("digest-{id}"), ())) + } + }, + ) + .unwrap(); + catalog["generators"] + .as_array() + .unwrap() + .iter() + .find(|entry| { + entry["pluginID"] == target.entry.plugin_id && entry["generator"]["id"] == "app" + }) + .unwrap()["requirements"][0] + .clone() + }; + let before = snapshot(&[]); + let healthy = snapshot(&["fixture.other-java"]); + assert_eq!(before["providers"].as_array().unwrap().len(), 2); + assert_eq!(healthy["providers"].as_array().unwrap().len(), 1); + assert_eq!(healthy["providers"][0]["pluginID"], "fixture.java"); + assert_ne!(before, healthy); + let missing = snapshot(&["fixture.java", "fixture.other-java"]); + assert!(missing["providers"].as_array().unwrap().is_empty()); + assert_ne!(healthy, missing); + } + #[test] + fn same_version_replacement_changes_full_package_identity() { + let root = std::path::Path::new("fixture-package"); + let first = identity(root, "installed", "1.0.0", "first-digest"); + assert_eq!(identity(root, "installed", "1.0.0", "first-digest"), first); + assert_ne!( + identity(root, "installed", "1.0.0", "replacement-digest"), + first + ); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/project_generators/mod.rs b/windows/tauri/src-tauri/src/plugin_host/project_generators/mod.rs new file mode 100644 index 0000000000..3579121cf4 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/project_generators/mod.rs @@ -0,0 +1,833 @@ +//! Generic, consent-bound Windows execution of Core project generation plans. +mod archive; +mod catalog; +#[cfg(test)] +mod tests; +mod transaction; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Path, PathBuf}; +#[cfg(test)] +use std::sync::atomic::AtomicU64; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Manager, WebviewWindow}; +use transaction::{Executor, Parent, Transaction}; + +type Result = std::result::Result; +const OPERATION_BUDGET: Duration = Duration::from_secs(600); +const REVIEW_TTL: Duration = Duration::from_secs(120); +const OUTPUT_LIMIT: usize = 2 * 1024 * 1024; +#[cfg(test)] +static NEXT_ID: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone, Serialize)] +pub struct Error { + pub code: String, + pub message: String, + pub details: Option, +} +impl Error { + fn new(code: &str) -> Self { + Self { + code: code.into(), + message: code.into(), + details: None, + } + } + fn io(error: std::io::Error) -> Self { + Self { + details: Some(error.kind().to_string()), + ..Self::new("generator.ioFailed") + } + } +} + +fn core(command: &str, payload: Value, key: &str) -> Result { + let value = super::scan::core_execute(command, payload) + .map_err(|_| Error::new("generator.coreFailed"))?; + if value["status"] != "ok" { + return Err(Error::new("generator.invalidInput")); + } + value + .get(key) + .cloned() + .ok_or_else(|| Error::new("generator.invalidPlan")) +} + +fn scope(window: &WebviewWindow) -> Result { + let label = window.label(); + if label != "main" && !label.starts_with("workspace-") { + return Err(Error::new("generator.invalidOwner")); + } + Ok(label.into()) +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Request { + plugin_id: String, + generator_id: String, + package_identity: String, + form: Value, + parent: String, + create_git: bool, +} + +#[derive(Clone)] +struct Program { + executable: PathBuf, + identity: Arc, + arguments: Vec, + environment: BTreeMap, +} + +struct Review { + owner: String, + request: Request, + plan: Value, + digest: String, + requirements: Value, + package_root: PathBuf, + parent: Parent, + programs: BTreeMap, + created: Instant, +} +struct Operation { + owner: String, + plugin: String, + target: PathBuf, + cancelled: AtomicBool, + download_cancel: super::marketplace_download::CancelToken, + publication: Mutex<()>, + finished: AtomicBool, + changed: tokio::sync::Notify, + // A failed process-tree cleanup retains the package mutation barrier. + lease: Mutex>, + cleanup: Mutex>, +} + +enum RetainedCleanup { + Filesystem(Transaction), + // The synchronous Core/Git runner does not return a process owner after + // uncertain cleanup. Do not remove staging or release its package lease. + UnconfirmedWriter { _transaction: Transaction }, +} + +impl Operation { + fn cancel(&self) { + let _order = self.publication.lock().expect("generator publication"); + self.cancelled.store(true, Ordering::Release); + self.download_cancel.cancel(); + } + fn checkpoint(&self, deadline: Instant) -> Result<()> { + if self.cancelled.load(Ordering::Acquire) { + return Err(Error::new("generator.cancelled")); + } + if Instant::now() >= deadline { + return Err(Error::new("generator.timeout")); + } + Ok(()) + } + + fn cleanup_finished(&self) -> Result<()> { + if !self.finished.load(Ordering::Acquire) { + return Err(Error::new("generator.cleanupFailed")); + } + let mut cleanup = self.cleanup.lock().expect("generator cleanup"); + match cleanup.as_ref() { + Some(RetainedCleanup::Filesystem(transaction)) => transaction.cleanup()?, + Some(RetainedCleanup::UnconfirmedWriter { .. }) => { + // The retained directory's existence is not evidence that a + // writer has terminated. This case requires process restart. + return Err(Error::new("generator.cleanupFailed")); + } + None if self.lease.lock().expect("generator lease").is_some() => { + return Err(Error::new("generator.cleanupFailed")); + } + None => return Ok(()), + } + cleanup.take(); + self.lease.lock().expect("generator lease").take(); + Ok(()) + } +} +#[derive(Default)] +struct Registry { + reviews: HashMap, + operations: HashMap>, + closed_owners: HashSet, +} +#[derive(Default)] +pub struct GeneratorState(Mutex, AtomicU8); + +fn package(app: &AppHandle, plugin_id: &str) -> Result { + if app.state::().1.load(Ordering::Acquire) != 0 { + return Err(Error::new("generator.inactive")); + } + if app + .state::() + .blocked(plugin_id) + { + return Err(Error::new("generator.inactive")); + } + super::launch::verified_package_by_id(app, plugin_id) + .map_err(|_| Error::new("generator.untrusted")) +} + +#[tauri::command] +pub async fn declarative_plugins_generator_catalog( + app: AppHandle, + window: WebviewWindow, +) -> Result { + scope(&window)?; + tauri::async_runtime::spawn_blocking(move || { + super::declarative_plugins_rescan(app.clone()) + .map_err(|_| Error::new("generator.coreFailed"))?; + catalog::load(&app) + }) + .await + .map_err(|_| Error::new("generator.workerFailed"))? +} + +#[tauri::command] +pub async fn declarative_plugins_generator_form( + app: AppHandle, + window: WebviewWindow, + plugin_id: String, + generator_id: String, + package_identity: String, + form: Value, +) -> Result { + scope(&window)?; + tauri::async_runtime::spawn_blocking(move || { + let _lease = app + .state::() + .acquire(&plugin_id) + .map_err(|_| Error::new("generator.inactive"))?; + let (package, identity) = catalog::read(&app, &plugin_id)?; + if identity != package_identity { + return Err(Error::new("generator.stale")); + } + let mut form = core( + "project.generatorForm", + json!({"manifest":package.manifest,"generatorID":generator_id,"form":form}), + "form", + )?; + form["packageIdentity"] = json!(identity); + Ok(form) + }) + .await + .map_err(|_| Error::new("generator.workerFailed"))? +} + +/// Java is the only runtime kind in the shared generator contract. Discovery +/// stays in the existing runtime adapter; fields never select commands to probe. +#[tauri::command] +pub async fn declarative_plugins_generator_runtime( + app: AppHandle, + window: WebviewWindow, + plugin_id: String, + home: String, +) -> Result { + scope(&window)?; + tauri::async_runtime::spawn_blocking(move || { + let _lease = app + .state::() + .acquire(&plugin_id) + .map_err(|_| Error::new("generator.inactive"))?; + package(&app, &plugin_id)?; + let parent = Parent::open(Path::new(&home))?; + crate::run::probe_java_home(&parent.path) + .ok_or_else(|| Error::new("generator.runtimeInvalid")) + }) + .await + .map_err(|_| Error::new("generator.workerFailed"))? +} + +fn plan(app: &AppHandle, request: &Request) -> Result<(super::ScannedPackage, Value)> { + // Preserve the launch trust gate, then refresh the installation state + // instead of relying on the scan cache's enabled flag. + package(app, &request.plugin_id)?; + let (package, identity) = catalog::read(app, &request.plugin_id)?; + if !package.entry.enabled { + return Err(Error::new("generator.inactive")); + } + if identity != request.package_identity { + return Err(Error::new("generator.stale")); + } + let plan = core( + "project.generationPlan", + json!({"manifest":package.manifest,"generatorID":request.generator_id, + "packagePath":package.root,"form":request.form}), + "plan", + )?; + Ok((package, plan)) +} + +fn set_env(environment: &mut BTreeMap, key: &str, value: String) { + environment.retain(|old, _| !old.eq_ignore_ascii_case(key)); + environment.insert(key.into(), value); +} + +fn resolve_program(step: &Value) -> Result { + let command = step["command"] + .as_str() + .ok_or_else(|| Error::new("generator.invalidPlan"))?; + // Like the reference host, only a Core-declared JAVA_HOME changes this + // command's runtime. Multiple runtime fields can serve different steps. + let java_home = step["environment"]["JAVA_HOME"] + .as_str() + .filter(|home| !home.is_empty()); + let executable = java_home + .and_then(|home| crate::run::jdk_tool_executable(Path::new(home), command)) + .or_else(|| crate::run::resolve_command_executable(command, &HashMap::new())) + .ok_or_else(|| Error::new("generator.toolMissing"))?; + if !executable.is_absolute() { + return Err(Error::new("generator.toolMissing")); + } + let executable = std::fs::canonicalize(executable).map_err(Error::io)?; + let identity = Arc::new(same_file::Handle::from_path(&executable).map_err(Error::io)?); + let arguments: Vec = serde_json::from_value(step["arguments"].clone()) + .map_err(|_| Error::new("generator.invalidPlan"))?; + // The shared launch adapter uses cmd.exe for batch shims. CALL reparses + // metacharacters, so reject those inputs rather than treating argv as shell text. + if executable + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat")) + && std::iter::once(executable.to_string_lossy().as_ref()) + .chain(arguments.iter().map(String::as_str)) + .any(|argument| argument.chars().any(|ch| "%!^&|<>()\r\n\"".contains(ch))) + { + return Err(Error::new("generator.batchArgumentUnsupported")); + } + let mut environment: BTreeMap = BTreeMap::new(); + for (key, value) in std::env::vars() { + set_env(&mut environment, &key, value); + } + if let Some(home) = java_home { + set_env(&mut environment, "JAVA_HOME", home.into()); + } + let overrides: BTreeMap = serde_json::from_value(step["environment"].clone()) + .map_err(|_| Error::new("generator.invalidPlan"))?; + for (key, value) in overrides { + set_env(&mut environment, &key, value); + } + let mut directories = java_home + .map(|home| Path::new(home).join("bin")) + .into_iter() + .collect::>(); + if let Some(parent) = executable.parent() { + directories.push(parent.to_path_buf()); + } + if let Some(path) = environment + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("PATH")) + .map(|(_, value)| value) + { + directories.extend(std::env::split_paths(path)); + } + let path = + std::env::join_paths(directories).map_err(|_| Error::new("generator.invalidPath"))?; + set_env( + &mut environment, + "PATH", + path.to_string_lossy().into_owned(), + ); + Ok(Program { + executable, + identity, + arguments, + environment, + }) +} + +#[tauri::command] +pub async fn declarative_plugins_generator_prepare( + app: AppHandle, + window: WebviewWindow, + request: Request, +) -> Result { + let owner = scope(&window)?; + tauri::async_runtime::spawn_blocking(move || { + let _lease = app.state::().acquire(&request.plugin_id).map_err(|_| Error::new("generator.inactive"))?; + let (package, plan) = plan(&app, &request)?; + let name = plan["projectName"].as_str().ok_or_else(|| Error::new("generator.invalidPlan"))?; + transaction::component(name)?; + let parent = Parent::open(Path::new(&request.parent))?; + transaction::absent(&parent.path.join(name))?; + let mut programs = BTreeMap::new(); + for (index, step) in plan["steps"].as_array().ok_or_else(|| Error::new("generator.invalidPlan"))?.iter().enumerate() { + if step["kind"] == "command" { programs.insert(index, resolve_program(step)?); } + } + let digest = super::signature::content_digest(&package.root).map_err(|_| Error::new("generator.untrusted"))?; + let requirements = catalog::requirements(&app, &request, &package)?; + let id = super::install::uuid_like_suffix(); + let displayed_steps: Vec<_> = plan["steps"].as_array().into_iter().flatten().map(|step| { + if step["kind"] != "writeFiles" { return step.clone(); } + json!({"kind":"writeFiles","files":step["files"].as_array().into_iter().flatten() + .map(|file| json!({"path":file["path"],"executable":file["executable"]})).collect::>()}) + }).collect(); + let response = json!({"id":id,"requirements":requirements,"plan":{"steps":displayed_steps,"usesNetwork":plan["usesNetwork"],"runtimes":plan["runtimes"]},"destination":parent.path.join(name), + "programs":programs.values().map(|program| json!({"executable":program.executable,"arguments":program.arguments})).collect::>()}); + let state = app.state::(); + let mut registry = state.0.lock().expect("generator reviews"); + if state.1.load(Ordering::Acquire) != 0 || registry.closed_owners.contains(&owner) { return Err(Error::new("generator.inactive")); } + registry.reviews.retain(|_, review| review.created.elapsed() < REVIEW_TTL); + if registry.reviews.len() >= 64 { return Err(Error::new("generator.busy")); } + registry.reviews.insert(id, Review { owner, request, plan, digest, requirements, package_root: package.root, parent, programs, created: Instant::now() }); + Ok(response) + }).await.map_err(|_| Error::new("generator.workerFailed"))? +} + +struct NativeExecutor<'a> { + review: &'a Review, + operation: &'a Operation, + operation_id: &'a str, +} +impl Executor for NativeExecutor<'_> { + fn archive( + &self, + step: &Value, + stage: &Path, + deadline: Instant, + checkpoint: &dyn Fn() -> Result<()>, + ) -> Result<()> { + if step["format"] != "zip" { + return Err(Error::new("generator.invalidPlan")); + } + let transport = super::marketplace_download::ReqwestTransport::new() + .map_err(|_| Error::new("generator.downloadFailed"))?; + archive::execute( + &transport, + step["url"] + .as_str() + .ok_or_else(|| Error::new("generator.invalidPlan"))?, + stage, + self.operation_id, + &self.operation.download_cancel, + deadline, + checkpoint, + ) + } + fn command( + &self, + index: usize, + step: &Value, + stage: &Path, + deadline: Instant, + checkpoint: &dyn Fn() -> Result<()>, + ) -> Result<()> { + checkpoint()?; + let program = self + .review + .programs + .get(&index) + .ok_or_else(|| Error::new("generator.invalidPlan"))?; + if same_file::Handle::from_path(&program.executable).map_err(Error::io)? + != *program.identity + { + return Err(Error::new("generator.stale")); + } + let mut command = crate::run::command_for_executable( + &program.executable.to_string_lossy(), + &program.arguments, + ); + command + .current_dir(stage) + .env_clear() + .envs(&program.environment); + let step_deadline = deadline.min( + Instant::now() + + Duration::from_secs(step["timeoutSeconds"].as_u64().unwrap_or(600).min(1800)), + ); + let bytes = std::cell::Cell::new(0usize); + let outcome = lithe_git_host::run( + &mut command, + None, + || self.operation.checkpoint(step_deadline).is_err() || bytes.get() > OUTPUT_LIMIT, + || {}, + |_, chunk| bytes.set(bytes.get().saturating_add(chunk.len())), + ); + if matches!(outcome.failure, Some(lithe_git_host::Failure::Cleanup)) { + return Err(Error::new("generator.cleanupFailed")); + } + checkpoint()?; + if Instant::now() >= step_deadline { + return Err(Error::new("generator.timeout")); + } + if bytes.get() > OUTPUT_LIMIT { + return Err(Error::new("generator.outputLimit")); + } + if outcome.failure.is_some() || !outcome.status.is_some_and(|status| status.success()) { + return Err(Error::new("generator.commandFailed")); + } + Ok(()) + } +} + +fn preset_runtime(transaction: &Transaction, plan: &Value) -> Result<()> { + let selections: Vec<_> = plan["runtimes"] + .as_array() + .into_iter() + .flatten() + .filter(|runtime| runtime["runtime"] == "java") + .collect(); + if selections.is_empty() { + return Ok(()); + } + let home = selections + .last() + .ok_or_else(|| Error::new("generator.invalidPlan"))?["home"] + .as_str() + .ok_or_else(|| Error::new("generator.invalidPlan"))?; + let document = super::scan::core_execute( + "runConfig.updateOptions", + json!({"root":transaction.stage, + "scope":"local","configurationId":"unused","toolchain":{"javaHomePath":home}}), + ) + .map_err(|_| Error::new("generator.coreFailed"))?; + transaction.write( + ".lithe/run/local.json", + document["document"] + .as_str() + .ok_or_else(|| Error::new("generator.invalidPlan"))? + .as_bytes(), + ) +} + +fn generate(app: &AppHandle, review: &Review, operation: &Operation, id: &str) -> Result { + let deadline = Instant::now() + OPERATION_BUDGET; + let checkpoint = || operation.checkpoint(deadline); + checkpoint()?; + let (package, fresh) = plan(app, &review.request)?; + if catalog::requirements(app, &review.request, &package)? != review.requirements { + return Err(Error::new("generator.stale")); + } + if fresh != review.plan + || package.root != review.package_root + || super::signature::content_digest(&package.root) + .map_err(|_| Error::new("generator.untrusted"))? + != review.digest + { + return Err(Error::new("generator.stale")); + } + for runtime in fresh["runtimes"].as_array().into_iter().flatten() { + checkpoint()?; + if runtime["runtime"] != "java" { + return Err(Error::new("generator.runtimeInvalid")); + } + let home = Parent::open(Path::new( + runtime["home"] + .as_str() + .ok_or_else(|| Error::new("generator.runtimeInvalid"))?, + ))?; + let probed = crate::run::probe_java_home(&home.path) + .ok_or_else(|| Error::new("generator.runtimeInvalid"))?; + if runtime["version"] != probed.version { + return Err(Error::new("generator.stale")); + } + } + review.parent.check()?; + let name = fresh["projectName"] + .as_str() + .ok_or_else(|| Error::new("generator.invalidPlan"))?; + let mut transaction = Transaction::create(review.parent.reopen()?, name, id)?; + let result = (|| { + transaction::execute( + &mut transaction, + &fresh, + &NativeExecutor { + review, + operation, + operation_id: &format!("generator-{id}"), + }, + deadline, + &checkpoint, + )?; + checkpoint()?; + preset_runtime(&transaction, &fresh)?; + if review.request.create_git { + checkpoint()?; + // Core's existing Git initializer has its own process deadline. The + // cancellation command forwards this operation ID while it runs. + let remaining = deadline + .saturating_duration_since(Instant::now()) + .as_millis() + .min(20000) + .max(1) as u64; + let request = json!({"command":"git.initialize","operationId":format!("generator-{id}"),"timeoutMilliseconds":remaining,"payload":{"root":transaction.stage}}); + let value = lithe_core::execute_json(&request.to_string()); + let value: Value = + serde_json::from_str(&value).map_err(|_| Error::new("generator.coreFailed"))?; + if value["ok"] != true { + // Core intentionally uses one process_failed category for both + // I/O failures and an unconfirmed process-tree cleanup. Retain + // ownership for that ambiguous category instead of parsing text. + if value["error"]["code"] == "process_failed" { + return Err(Error::new("generator.cleanupFailed")); + } + return Err(Error::new("generator.gitFailed")); + } + } + let relative_open_files: Vec = fresh["openFiles"] + .as_array() + .into_iter() + .flatten() + .map(|path| { + path.as_str() + .ok_or_else(|| Error::new("generator.invalidPlan")) + .and_then(transaction::relative) + }) + .collect::>()?; + transaction.validate(&checkpoint)?; + let _publication = operation.publication.lock().expect("generator publication"); + checkpoint()?; + let root = transaction.publish(&checkpoint)?; + let open_files: Vec<_> = relative_open_files + .into_iter() + .map(|path| root.join(path)) + .collect(); + Ok(json!({"root":root,"openFiles":open_files})) + })(); + // A process-tree cleanup failure may leave a writer alive. Keep both the + // stage and NativeLease, preventing an unsafe plugin replacement. + if result + .as_ref() + .err() + .is_some_and(|error| error.code == "generator.cleanupFailed") + { + *operation.cleanup.lock().expect("generator cleanup") = + Some(RetainedCleanup::UnconfirmedWriter { + _transaction: transaction, + }); + return result; + } + if let Err(error) = transaction.cleanup() { + *operation.cleanup.lock().expect("generator cleanup") = + Some(RetainedCleanup::Filesystem(transaction)); + return Err(error); + } + result +} + +#[tauri::command] +pub async fn declarative_plugins_generator_generate( + app: AppHandle, + window: WebviewWindow, + id: String, +) -> Result { + let owner = scope(&window)?; + let (review, operation) = { + let state = app.state::(); + let mut registry = state.0.lock().expect("generator registry"); + if state.1.load(Ordering::Acquire) != 0 || registry.closed_owners.contains(&owner) { + return Err(Error::new("generator.inactive")); + } + if registry.operations.len() >= 8 { + return Err(Error::new("generator.busy")); + } + if registry + .reviews + .get(&id) + .is_none_or(|review| review.owner != owner) + { + return Err(Error::new("generator.stale")); + } + let review = registry + .reviews + .remove(&id) + .ok_or_else(|| Error::new("generator.stale"))?; + if review.created.elapsed() >= REVIEW_TTL { + return Err(Error::new("generator.stale")); + } + let target = review.parent.path.join( + review.plan["projectName"] + .as_str() + .ok_or_else(|| Error::new("generator.invalidPlan"))?, + ); + if registry.operations.values().any(|operation| { + operation + .target + .to_string_lossy() + .eq_ignore_ascii_case(&target.to_string_lossy()) + }) { + return Err(Error::new("generator.busy")); + } + let lease = app + .state::() + .acquire(&review.request.plugin_id) + .map_err(|_| Error::new("generator.inactive"))?; + let operation = Arc::new(Operation { + owner, + plugin: review.request.plugin_id.clone(), + target, + cancelled: AtomicBool::new(false), + download_cancel: super::marketplace_download::CancelToken::new(), + publication: Mutex::new(()), + finished: AtomicBool::new(false), + changed: tokio::sync::Notify::new(), + lease: Mutex::new(Some(lease)), + cleanup: Mutex::new(None), + }); + registry.operations.insert(id.clone(), operation.clone()); + (review, operation) + }; + tauri::async_runtime::spawn_blocking(move || { + let result = generate(&app, &review, &operation, &id); + if !result + .as_ref() + .err() + .is_some_and(|error| error.code == "generator.cleanupFailed") + { + operation.lease.lock().expect("generator lease").take(); + app.state::() + .0 + .lock() + .expect("generator registry") + .operations + .remove(&id); + } + operation.finished.store(true, Ordering::Release); + operation.changed.notify_waiters(); + result + }) + .await + .map_err(|_| Error::new("generator.workerFailed"))? +} + +async fn stop( + app: &AppHandle, + owner: Option<&str>, + plugin: Option<&str>, + id: Option<&str>, +) -> Result<()> { + let operations: Vec<_> = { + let state = app.state::(); + let mut registry = state.0.lock().expect("generator registry"); + registry.reviews.retain(|key, review| { + !(owner.is_none_or(|owner| review.owner == owner) + && plugin.is_none_or(|plugin| review.request.plugin_id == plugin) + && id.is_none_or(|id| key == id)) + }); + registry + .operations + .iter() + .filter(|(key, operation)| { + owner.is_none_or(|owner| operation.owner == owner) + && plugin.is_none_or(|plugin| operation.plugin == plugin) + && id.is_none_or(|id| key.as_str() == id) + }) + .map(|(key, operation)| (key.clone(), operation.clone())) + .collect() + }; + tokio::time::timeout(Duration::from_secs(12), async { + let cancellation = operations.clone(); + tauri::async_runtime::spawn_blocking(move || { + for (id, operation) in cancellation { + operation.cancel(); + lithe_core::cancel_operation(&format!("generator-{id}")); + } + }) + .await + .map_err(|_| Error::new("generator.cleanupFailed"))?; + for (id, operation) in operations { + loop { + let changed = operation.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + if operation.finished.load(Ordering::Acquire) { + let retained = operation.lease.lock().expect("generator lease").is_some(); + if retained { + let retry = operation.clone(); + tauri::async_runtime::spawn_blocking(move || retry.cleanup_finished()) + .await + .map_err(|_| Error::new("generator.cleanupFailed"))??; + } + let state = app.state::(); + let mut registry = state.0.lock().expect("generator registry"); + if registry + .operations + .get(&id) + .is_some_and(|current| Arc::ptr_eq(current, &operation)) + { + registry.operations.remove(&id); + } + break; + } + // A cancellation can precede Core's nested Git registration. + // Retry against that public operation boundary until it exits. + tokio::select! { + _ = changed => {}, + _ = tokio::time::sleep(Duration::from_millis(25)) => { + lithe_core::cancel_operation(&format!("generator-{id}")); + } + } + } + } + Ok(()) + }) + .await + .map_err(|_| Error::new("generator.cleanupFailed"))? +} + +#[tauri::command] +pub async fn declarative_plugins_generator_cancel( + app: AppHandle, + window: WebviewWindow, + id: String, +) -> Result<()> { + stop(&app, Some(&scope(&window)?), None, Some(&id)).await +} +pub async fn close_plugin(app: &AppHandle, plugin: &str) -> Result<()> { + stop(app, None, Some(plugin), None).await +} +pub async fn close_window(app: &AppHandle, owner: &str) -> Result<()> { + window_destroyed(app, owner); + stop(app, Some(owner), None, None).await +} + +pub fn window_destroyed(app: &AppHandle, owner: &str) { + let state = app.state::(); + let mut registry = state.0.lock().expect("generator registry"); + registry.closed_owners.insert(owner.into()); + registry.reviews.retain(|_, review| review.owner != owner); + for operation in registry + .operations + .values() + .filter(|operation| operation.owner == owner) + { + operation.cancelled.store(true, Ordering::Release); + operation.download_cancel.cancel(); + } +} + +/// Defer exit once, without blocking the event/UI thread, while the existing +/// native process owner drains. A second quit request cannot bypass the drain. +pub fn defer_exit(app: &AppHandle, code: Option) -> bool { + let state = app.state::(); + match state + .1 + .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire) + { + Ok(_) => { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = stop(&app, None, None, None).await { + eprintln!( + "Project generator shutdown retained staging: {}", + error.code + ); + } + app.state::().1.store(2, Ordering::Release); + app.exit(code.unwrap_or(0)); + }); + true + } + Err(1) => true, + Err(_) => false, + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/project_generators/tests.rs b/windows/tauri/src-tauri/src/plugin_host/project_generators/tests.rs new file mode 100644 index 0000000000..988519c429 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/project_generators/tests.rs @@ -0,0 +1,1089 @@ +use super::*; +use std::fs; + +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let token = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "lithe-generator-test-{}-{token}-{}", + std::process::id(), + NEXT_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + fn transaction(&self) -> Transaction { + Transaction::create(Parent::open(&self.0).unwrap(), "Example", "test").unwrap() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).expect("remove generator fixture"); + } +} + +fn fixture_plan(id: &str) -> Value { + let package = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../shared/contracts/plugins/v1/fixtures"); + let manifest: Value = serde_json::from_str( + &fs::read_to_string(package.join("project-generator.plugin.json")).unwrap(), + ) + .unwrap(); + core("project.generationPlan", json!({"manifest":manifest,"generatorID":id,"packagePath":package, + "form":{"projectName":"Example","runtimes":{"jdk":{"home":"C:/fixture/jdk","version":"21"}}}}), "plan").unwrap() +} +struct NoCommand; +impl Executor for NoCommand { + fn command( + &self, + _: usize, + _: &Value, + _: &Path, + _: Instant, + _: &dyn Fn() -> Result<()>, + ) -> Result<()> { + panic!("files plan must not run a command") + } +} +fn bounded() -> (Instant, impl Fn() -> Result<()>) { + let deadline = Instant::now() + Duration::from_secs(3); + (deadline, move || { + if Instant::now() >= deadline { + Err(Error::new("generator.timeout")) + } else { + Ok(()) + } + }) +} + +#[test] +fn real_core_files_plan_publishes_a_complete_project_without_changing_package() { + let fixture = Fixture::new(); + let plan = fixture_plan("app"); + let mut transaction = fixture.transaction(); + let (deadline, checkpoint) = bounded(); + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join( + "../../../shared/contracts/plugins/v1/fixtures/project-generator-templates/app/README.md", + ); + let original = fs::read(&source).unwrap(); + transaction::execute(&mut transaction, &plan, &NoCommand, deadline, &checkpoint).unwrap(); + assert!(!fixture.0.join("Example").exists()); + #[cfg(windows)] + { + let target = transaction.publish(&checkpoint).unwrap(); + assert!(target.join("README.md").is_file()); + assert!(target.join(".gitignore").is_file()); + assert!(fs::read_to_string(target.join("README.md")) + .unwrap() + .contains("Example")); + } + assert_eq!(fs::read(source).unwrap(), original); + transaction.cleanup().unwrap(); +} + +#[cfg(windows)] +#[test] +fn activated_maven_sample_form_writes_main_java_through_the_windows_transaction() { + let fixture = Fixture::new(); + let package = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../shared/contracts/plugins/v1/fixtures"); + let manifest: Value = serde_json::from_str( + &fs::read_to_string(package.join("project-generator.plugin.json")).unwrap(), + ) + .unwrap(); + let form = json!({"projectName":"Example","values":{"buildSystem":"maven","sample":true,"groupId":"org.example"}, + "edited":["groupId"],"runtimes":{"jdk":{"home":"C:/fixture/jdk","version":"21"}}}); + let evaluation = core( + "project.generatorForm", + json!({"manifest":manifest,"generatorID":"app","form":form}), + "form", + ) + .unwrap(); + assert_eq!(evaluation["valid"], true); + assert!(evaluation["activeRequirements"] + .as_array() + .unwrap() + .contains(&json!(1))); + let plan = core( + "project.generationPlan", + json!({"manifest":manifest,"generatorID":"app","packagePath":package,"form":form}), + "plan", + ) + .unwrap(); + let mut transaction = fixture.transaction(); + let (deadline, checkpoint) = bounded(); + transaction::execute(&mut transaction, &plan, &NoCommand, deadline, &checkpoint).unwrap(); + let target = transaction.publish(&checkpoint).unwrap(); + assert!(target.join("src/org/example/Main.java").is_file()); + assert!(fs::read_to_string(target.join("src/org/example/Main.java")) + .unwrap() + .contains("package org.example;")); + assert!(plan["openFiles"] + .as_array() + .unwrap() + .contains(&json!("src/org/example/Main.java"))); + assert!(!transaction.stage.exists()); + transaction.cleanup().unwrap(); +} + +struct GenerateCollected; +impl Executor for GenerateCollected { + fn command( + &self, + _: usize, + step: &Value, + stage: &Path, + _: Instant, + checkpoint: &dyn Fn() -> Result<()>, + ) -> Result<()> { + checkpoint()?; + assert_eq!(step["command"], "mvn"); + assert!(step["arguments"] + .as_array() + .unwrap() + .contains(&json!("-DartifactId=example"))); + let child = stage.join(step["collect"].as_str().unwrap()); + fs::create_dir(&child).unwrap(); + fs::write(child.join("pom.xml"), "").unwrap(); + Ok(()) + } +} +#[cfg(windows)] +#[test] +fn real_core_command_plan_collects_then_publishes_once() { + let fixture = Fixture::new(); + let mut transaction = fixture.transaction(); + let (deadline, checkpoint) = bounded(); + transaction::execute( + &mut transaction, + &fixture_plan("archetype"), + &GenerateCollected, + deadline, + &checkpoint, + ) + .unwrap(); + let target = transaction.publish(&checkpoint).unwrap(); + assert!(target.join("pom.xml").is_file()); + assert!(!target.join("example").exists()); + assert!(!transaction.stage.exists()); + transaction.cleanup().unwrap(); +} + +#[test] +fn existing_destination_is_never_changed_even_if_it_appears_after_generation() { + let fixture = Fixture::new(); + let mut transaction = fixture.transaction(); + let (deadline, checkpoint) = bounded(); + transaction::execute( + &mut transaction, + &fixture_plan("app"), + &NoCommand, + deadline, + &checkpoint, + ) + .unwrap(); + fs::create_dir(fixture.0.join("Example")).unwrap(); + fs::write(fixture.0.join("Example/owned.txt"), "existing").unwrap(); + assert_eq!( + transaction.publish(&checkpoint).unwrap_err().code, + "generator.destinationExists" + ); + transaction.cleanup().unwrap(); + assert_eq!( + fs::read_to_string(fixture.0.join("Example/owned.txt")).unwrap(), + "existing" + ); + assert!(!fixture.0.join("Example/README.md").exists()); +} + +#[cfg(windows)] +#[test] +fn two_staging_transactions_cannot_overwrite_one_target() { + let fixture = Fixture::new(); + let mut first = + Transaction::create(Parent::open(&fixture.0).unwrap(), "Example", "first").unwrap(); + let mut second = + Transaction::create(Parent::open(&fixture.0).unwrap(), "Example", "second").unwrap(); + let (deadline, checkpoint) = bounded(); + let plan = fixture_plan("app"); + transaction::execute(&mut first, &plan, &NoCommand, deadline, &checkpoint).unwrap(); + transaction::execute(&mut second, &plan, &NoCommand, deadline, &checkpoint).unwrap(); + first.publish(&checkpoint).unwrap(); + assert_eq!( + second.publish(&checkpoint).unwrap_err().code, + "generator.destinationExists" + ); + second.cleanup().unwrap(); + assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1); +} + +#[test] +fn selected_java_runtime_is_saved_using_the_existing_core_configuration_document() { + let fixture = Fixture::new(); + let mut transaction = fixture.transaction(); + let (deadline, checkpoint) = bounded(); + let plan = fixture_plan("app"); + transaction::execute(&mut transaction, &plan, &NoCommand, deadline, &checkpoint).unwrap(); + preset_runtime(&transaction, &plan).unwrap(); + let document: Value = serde_json::from_str( + &fs::read_to_string(transaction.stage.join(".lithe/run/local.json")).unwrap(), + ) + .unwrap(); + assert_eq!(document["version"], 2); + assert_eq!(document["toolchain"]["java"]["homePath"], "C:/fixture/jdk"); + transaction.cleanup().unwrap(); +} + +struct FailedCommand; +impl Executor for FailedCommand { + fn command( + &self, + _: usize, + _: &Value, + stage: &Path, + _: Instant, + _: &dyn Fn() -> Result<()>, + ) -> Result<()> { + fs::write(stage.join("partial.txt"), "partial").unwrap(); + Err(Error::new("generator.commandFailed")) + } +} +#[test] +fn command_failure_cleans_partial_files_and_never_publishes() { + let fixture = Fixture::new(); + let mut transaction = fixture.transaction(); + let (deadline, checkpoint) = bounded(); + assert_eq!( + transaction::execute( + &mut transaction, + &fixture_plan("archetype"), + &FailedCommand, + deadline, + &checkpoint + ) + .unwrap_err() + .code, + "generator.commandFailed" + ); + transaction.cleanup().unwrap(); + assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 0); +} + +struct CancelCommand(Arc); +impl Executor for CancelCommand { + fn command( + &self, + _: usize, + _: &Value, + stage: &Path, + _: Instant, + checkpoint: &dyn Fn() -> Result<()>, + ) -> Result<()> { + fs::write(stage.join("partial.txt"), "partial").unwrap(); + self.0.store(true, Ordering::Release); + checkpoint() + } +} +#[test] +fn cancellation_at_command_boundary_cleans_the_transaction() { + let fixture = Fixture::new(); + let mut transaction = fixture.transaction(); + let cancelled = Arc::new(AtomicBool::new(false)); + let (deadline, budget) = bounded(); + let checkpoint = || { + budget()?; + if cancelled.load(Ordering::Acquire) { + Err(Error::new("generator.cancelled")) + } else { + Ok(()) + } + }; + assert_eq!( + transaction::execute( + &mut transaction, + &fixture_plan("archetype"), + &CancelCommand(cancelled.clone()), + deadline, + &checkpoint + ) + .unwrap_err() + .code, + "generator.cancelled" + ); + transaction.cleanup().unwrap(); + assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 0); +} + +#[test] +fn traversal_ads_reserved_names_and_template_overwrite_are_rejected() { + let fixture = Fixture::new(); + let transaction = fixture.transaction(); + for path in [ + "../escape", + "nested/../escape", + "C:/escape", + "file:stream", + "a\\b", + "a/CON.txt", + "a/trailing.", + ] { + assert!(transaction.write(path, b"bad").is_err(), "{path}"); + } + transaction.write("safe.txt", b"first").unwrap(); + assert!(transaction.write("safe.txt", b"second").is_err()); + assert_eq!( + fs::read(transaction.stage.join("safe.txt")).unwrap(), + b"first" + ); + transaction.cleanup().unwrap(); +} + +#[cfg(windows)] +#[test] +fn hard_link_output_is_rejected_without_touching_its_source() { + let fixture = Fixture::new(); + let transaction = fixture.transaction(); + let outside = fixture.0.join("outside.txt"); + fs::write(&outside, "owned").unwrap(); + fs::hard_link(&outside, transaction.stage.join("linked.txt")).unwrap(); + let (_, checkpoint) = bounded(); + assert_eq!( + transaction.validate(&checkpoint).unwrap_err().code, + "generator.linkRejected" + ); + transaction.cleanup().unwrap(); + assert_eq!(fs::read_to_string(outside).unwrap(), "owned"); +} + +#[test] +fn archive_plan_requires_an_explicit_native_archive_executor() { + let fixture = Fixture::new(); + let mut transaction = fixture.transaction(); + let (deadline, checkpoint) = bounded(); + assert_eq!( + transaction::execute( + &mut transaction, + &fixture_plan("initializr"), + &NoCommand, + deadline, + &checkpoint + ) + .unwrap_err() + .code, + "generator.archiveUnavailable" + ); + transaction.cleanup().unwrap(); + assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 0); +} + +#[test] +fn production_catalog_does_not_bypass_closed_host_capabilities() { + let package = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../shared/contracts/plugins/v1/fixtures/project-generator.plugin.json"); + let manifest: Value = serde_json::from_str(&fs::read_to_string(package).unwrap()).unwrap(); + let catalog = core("project.generatorCatalog", json!({"plugins":[{"manifest":manifest,"enabled":true}],"hostCapabilities":super::super::host_capabilities()}), "catalog").unwrap(); + assert!(catalog["generators"].as_array().unwrap().is_empty()); + assert!(!catalog["issues"].as_array().unwrap().is_empty()); +} + +fn retained_operation( + lifecycle: &super::super::lifecycle::LifecycleState, + transaction: Transaction, + unconfirmed_writer: bool, +) -> Operation { + let target = transaction.stage.clone(); + Operation { + owner: "main".into(), + plugin: "fixture.generator".into(), + target, + cancelled: AtomicBool::new(true), + download_cancel: super::super::marketplace_download::CancelToken::new(), + publication: Mutex::new(()), + finished: AtomicBool::new(true), + changed: tokio::sync::Notify::new(), + lease: Mutex::new(Some(lifecycle.acquire("fixture.generator").unwrap())), + cleanup: Mutex::new(Some(if unconfirmed_writer { + RetainedCleanup::UnconfirmedWriter { + _transaction: transaction, + } + } else { + RetainedCleanup::Filesystem(transaction) + })), + } +} + +#[cfg(windows)] +#[test] +fn finished_filesystem_cleanup_retries_the_same_directory_before_releasing_its_lease() { + use std::os::windows::fs::OpenOptionsExt; + let fixture = Fixture::new(); + let transaction = fixture.transaction(); + transaction.write("locked.txt", b"partial").unwrap(); + let stage = transaction.stage.clone(); + let lock = fs::OpenOptions::new() + .read(true) + .share_mode(0) + .open(stage.join("locked.txt")) + .unwrap(); + let lifecycle = super::super::lifecycle::LifecycleState::default(); + let operation = retained_operation(&lifecycle, transaction, false); + assert_eq!( + operation.cleanup_finished().unwrap_err().code, + "generator.cleanupFailed" + ); + assert!(operation.lease.lock().unwrap().is_some()); + assert!(stage.is_dir()); + drop(lock); + operation.cleanup_finished().unwrap(); + assert!(!stage.exists()); + assert!(operation.lease.lock().unwrap().is_none()); + operation.cleanup_finished().unwrap(); +} + +#[test] +fn uncertain_writer_cleanup_never_removes_staging_or_releases_its_lease() { + let fixture = Fixture::new(); + let transaction = fixture.transaction(); + transaction.write("partial.txt", b"writer output").unwrap(); + let stage = transaction.stage.clone(); + let lifecycle = super::super::lifecycle::LifecycleState::default(); + let operation = retained_operation(&lifecycle, transaction, true); + for _ in 0..2 { + assert_eq!( + operation.cleanup_finished().unwrap_err().code, + "generator.cleanupFailed" + ); + assert!(operation.lease.lock().unwrap().is_some()); + assert_eq!( + fs::read(stage.join("partial.txt")).unwrap(), + b"writer output" + ); + } + drop(operation); +} + +#[test] +fn one_owner_cannot_cancel_another_operations_download_or_remove_its_stage() { + let first = Fixture::new(); + let second = Fixture::new(); + let lifecycle = super::super::lifecycle::LifecycleState::default(); + let mut operation = retained_operation(&lifecycle, first.transaction(), false); + operation.owner = "main".into(); + let mut other = retained_operation(&lifecycle, second.transaction(), false); + other.owner = "workspace-other".into(); + other.cancelled.store(false, Ordering::Release); + operation.cancel(); + operation.cleanup_finished().unwrap(); + assert!(operation.download_cancel.is_cancelled()); + assert!(!other.download_cancel.is_cancelled()); + assert!(other + .checkpoint(Instant::now() + Duration::from_secs(3)) + .is_ok()); + assert!(other.target.is_dir()); + assert!(other.lease.lock().unwrap().is_some()); + other.cleanup_finished().unwrap(); +} + +struct ArchiveTransport { + responses: Mutex>, + requests: Mutex>, + before_response: Option>, +} +impl ArchiveTransport { + fn zip(entries: &[(&str, &[u8])]) -> Vec { + use std::io::Write; + let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in entries { + writer + .start_file(*name, zip::write::SimpleFileOptions::default()) + .unwrap(); + writer.write_all(bytes).unwrap(); + } + writer.finish().unwrap().into_inner() + } + fn new(body: Vec) -> Self { + Self { + responses: Mutex::new(std::collections::VecDeque::from([ + super::super::marketplace_download::HttpResponse { + status: 200, + headers: HashMap::new(), + body, + }, + ])), + requests: Mutex::new(Vec::new()), + before_response: None, + } + } +} +impl super::super::marketplace_download::HttpTransport for ArchiveTransport { + fn get<'a>( + &'a self, + url: &'a str, + max_bytes: u64, + _: Instant, + _: &'a super::super::marketplace_download::CancelToken, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = std::result::Result< + super::super::marketplace_download::HttpResponse, + super::super::marketplace_download::FetchFailure, + >, + > + Send + + 'a, + >, + > { + Box::pin(async move { + self.requests.lock().unwrap().push(url.into()); + if let Some(before_response) = &self.before_response { + before_response(); + } + let response = self + .responses + .lock() + .unwrap() + .pop_front() + .expect("unexpected archive request"); + if response.body.len() as u64 > max_bytes { + return Err( + super::super::marketplace_download::FetchFailure::BudgetExceeded { + limit: max_bytes, + }, + ); + } + Ok(response) + }) + } +} +struct NetworkArchive<'a> { + transport: &'a ArchiveTransport, + token: super::super::marketplace_download::CancelToken, +} +impl Executor for NetworkArchive<'_> { + fn command( + &self, + _: usize, + _: &Value, + _: &Path, + _: Instant, + _: &dyn Fn() -> Result<()>, + ) -> Result<()> { + panic!("archive plan must not run commands") + } + fn archive( + &self, + step: &Value, + stage: &Path, + deadline: Instant, + checkpoint: &dyn Fn() -> Result<()>, + ) -> Result<()> { + archive::execute( + self.transport, + step["url"].as_str().unwrap(), + stage, + "generator-fixture-archive", + &self.token, + deadline, + checkpoint, + ) + } +} + +#[cfg(windows)] +fn create_junction(link: &Path, target: &Path) { + let link_deadline = Instant::now() + Duration::from_secs(5); + let mut command = std::process::Command::new("cmd"); + command + .args(["/D", "/C", "mklink", "/J"]) + .arg(link) + .arg(target); + let outcome = lithe_git_host::run( + &mut command, + None, + || Instant::now() >= link_deadline, + || {}, + |_, _| {}, + ); + assert!( + outcome.failure.is_none() && outcome.status.is_some_and(|status| status.success()), + "junction creation failed: {}", + String::from_utf8_lossy(&outcome.stderr) + ); +} + +#[cfg(windows)] +#[test] +fn archive_download_rechecks_stage_identity_and_retains_the_original_cleanup_owner() { + for junction in [false, true] { + let fixture = Fixture::new(); + let mut transaction = fixture.transaction(); + let stage = transaction.stage.clone(); + let moved = fixture.0.join("original-staging"); + let replacement = fixture.0.join("prepared-replacement"); + let replacement_contents = if junction { + let outside = fixture.0.join("junction-target"); + fs::create_dir(&outside).unwrap(); + fs::write(outside.join("owned.txt"), b"user bytes").unwrap(); + create_junction(&replacement, &outside); + outside + } else { + fs::create_dir(&replacement).unwrap(); + fs::write(replacement.join("owned.txt"), b"user bytes").unwrap(); + stage.clone() + }; + let mut transport = + ArchiveTransport::new(ArchiveTransport::zip(&[("untrusted.txt", b"no write")])); + let swapped_stage = stage.clone(); + let moved_stage = moved.clone(); + transport.before_response = Some(Box::new(move || { + // Replace staging only after the pre-fetch transaction check. No + // worker or clock race controls this security regression scenario. + fs::rename(&swapped_stage, &moved_stage).unwrap(); + fs::rename(&replacement, &swapped_stage).unwrap(); + })); + let executor = NetworkArchive { + transport: &transport, + token: super::super::marketplace_download::CancelToken::new(), + }; + let (deadline, checkpoint) = bounded(); + let error = transaction::execute( + &mut transaction, + &fixture_plan("initializr"), + &executor, + deadline, + &checkpoint, + ) + .unwrap_err(); + assert!(matches!( + error.code.as_str(), + "generator.stale" | "generator.linkRejected" + )); + assert_eq!(transport.requests.lock().unwrap().len(), 1); + assert_eq!( + fs::read(replacement_contents.join("owned.txt")).unwrap(), + b"user bytes" + ); + assert_eq!(fs::read_dir(&replacement_contents).unwrap().count(), 1); + assert_eq!(fs::read_dir(&moved).unwrap().count(), 0); + let lifecycle = super::super::lifecycle::LifecycleState::default(); + let operation = retained_operation(&lifecycle, transaction, false); + assert_eq!( + operation.cleanup_finished().unwrap_err().code, + "generator.cleanupFailed" + ); + assert!(operation.lease.lock().unwrap().is_some()); + assert_eq!(fs::read_dir(&replacement_contents).unwrap().count(), 1); + + // Only the owned test replacement is removed. Restoring the original + // identity makes the retained operation's cleanup retry succeed. + if !junction { + fs::remove_file(stage.join("owned.txt")).unwrap(); + } + fs::remove_dir(&stage).unwrap(); + fs::rename(&moved, &stage).unwrap(); + operation.cleanup_finished().unwrap(); + assert!(operation.lease.lock().unwrap().is_none()); + assert!(!stage.exists()); + } +} + +#[cfg(windows)] +#[test] +fn archive_step_reuses_the_real_core_extractor_before_exclusive_publication() { + let fixture = Fixture::new(); + let mut transaction = fixture.transaction(); + let transport = ArchiveTransport::new(ArchiveTransport::zip(&[ + ("pom.xml", b""), + ("src/App.java", b"class App {}"), + ])); + let executor = NetworkArchive { + transport: &transport, + token: super::super::marketplace_download::CancelToken::new(), + }; + let plan = fixture_plan("initializr"); + let (deadline, checkpoint) = bounded(); + transaction::execute(&mut transaction, &plan, &executor, deadline, &checkpoint).unwrap(); + assert_eq!( + transport.requests.lock().unwrap().as_slice(), + [plan["steps"][0]["url"].as_str().unwrap()] + ); + assert!(!fixture.0.join("Example").exists()); + assert_eq!( + fs::read_dir(&transaction.stage).unwrap().count(), + 2, + "private archive must be removed" + ); + let target = transaction.publish(&checkpoint).unwrap(); + assert_eq!( + fs::read_to_string(target.join("src/App.java")).unwrap(), + "class App {}" + ); + transaction.cleanup().unwrap(); +} + +#[test] +fn hostile_archive_or_preexisting_target_file_is_never_published_or_overwritten() { + for name in [ + "../escape.txt", + "nested/../escape.txt", + "nested\\escape.txt", + "owned.txt", + ] { + let fixture = Fixture::new(); + let mut transaction = fixture.transaction(); + transaction.write("owned.txt", b"user bytes").unwrap(); + let transport = ArchiveTransport::new(ArchiveTransport::zip(&[(name, b"bad")])); + let executor = NetworkArchive { + transport: &transport, + token: super::super::marketplace_download::CancelToken::new(), + }; + let (deadline, checkpoint) = bounded(); + assert_eq!( + transaction::execute( + &mut transaction, + &fixture_plan("initializr"), + &executor, + deadline, + &checkpoint + ) + .unwrap_err() + .code, + "generator.archiveInvalid" + ); + assert_eq!( + fs::read(transaction.stage.join("owned.txt")).unwrap(), + b"user bytes" + ); + assert!(!fixture.0.join("escape.txt").exists()); + assert!(!fixture.0.join("Example").exists()); + transaction.cleanup().unwrap(); + assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 0); + } +} + +#[cfg(windows)] +#[test] +fn nested_archive_directories_are_preserved_and_a_late_target_never_changes() { + let fixture = Fixture::new(); + let mut transaction = fixture.transaction(); + let transport = ArchiveTransport::new(ArchiveTransport::zip(&[( + "wrapper/src/App.java", + b"class App {}", + )])); + let executor = NetworkArchive { + transport: &transport, + token: super::super::marketplace_download::CancelToken::new(), + }; + let (deadline, checkpoint) = bounded(); + transaction::execute( + &mut transaction, + &fixture_plan("initializr"), + &executor, + deadline, + &checkpoint, + ) + .unwrap(); + // The shared plan has no strip field. Preserve the ZIP's enclosing folder + // rather than guessing it is a disposable archive wrapper. + assert!(transaction.stage.join("wrapper/src/App.java").is_file()); + assert!(!transaction.stage.join("src").exists()); + fs::create_dir(fixture.0.join("Example")).unwrap(); + fs::write(fixture.0.join("Example/owned.txt"), b"user bytes").unwrap(); + assert_eq!( + transaction.publish(&checkpoint).unwrap_err().code, + "generator.destinationExists" + ); + transaction.cleanup().unwrap(); + assert_eq!( + fs::read(fixture.0.join("Example/owned.txt")).unwrap(), + b"user bytes" + ); + assert_eq!(fs::read_dir(fixture.0.join("Example")).unwrap().count(), 1); +} + +#[test] +fn invalid_zip_and_archive_symlink_remove_the_download_before_transaction_cleanup() { + use std::io::Cursor; + let mut linked = zip::ZipWriter::new(Cursor::new(Vec::new())); + linked + .add_symlink( + "link", + "../owned.txt", + zip::write::SimpleFileOptions::default(), + ) + .unwrap(); + let linked = linked.finish().unwrap().into_inner(); + for body in [b"not a zip".to_vec(), linked] { + let fixture = Fixture::new(); + let mut transaction = fixture.transaction(); + let transport = ArchiveTransport::new(body); + let executor = NetworkArchive { + transport: &transport, + token: super::super::marketplace_download::CancelToken::new(), + }; + let (deadline, checkpoint) = bounded(); + assert_eq!( + transaction::execute( + &mut transaction, + &fixture_plan("initializr"), + &executor, + deadline, + &checkpoint + ) + .unwrap_err() + .code, + "generator.archiveInvalid" + ); + assert_eq!(fs::read_dir(&transaction.stage).unwrap().count(), 0); + transaction.cleanup().unwrap(); + assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 0); + } +} + +#[cfg(windows)] +#[test] +fn real_core_refuses_archive_writes_through_a_junction_and_cleanup_keeps_its_target() { + let fixture = Fixture::new(); + let mut transaction = fixture.transaction(); + let outside = fixture.0.join("outside"); + fs::create_dir(&outside).unwrap(); + fs::write(outside.join("owned.txt"), b"user bytes").unwrap(); + let link: PathBuf = transaction.stage.join("nested").components().collect(); + let target: PathBuf = outside.components().collect(); + // Reuse the existing build/web fixture's junction technique: no Developer + // Mode or admin permission, with the native process owner and a deadline. + create_junction(&link, &target); + let transport = ArchiveTransport::new(ArchiveTransport::zip(&[( + "nested/escape.txt", + b"untrusted", + )])); + let executor = NetworkArchive { + transport: &transport, + token: super::super::marketplace_download::CancelToken::new(), + }; + let (deadline, checkpoint) = bounded(); + assert_eq!( + transaction::execute( + &mut transaction, + &fixture_plan("initializr"), + &executor, + deadline, + &checkpoint + ) + .unwrap_err() + .code, + "generator.archiveInvalid" + ); + assert!(!outside.join("escape.txt").exists()); + transaction.cleanup().unwrap(); + assert_eq!(fs::read(outside.join("owned.txt")).unwrap(), b"user bytes"); + assert_eq!(fs::read_dir(outside).unwrap().count(), 1); +} + +#[test] +fn denied_archive_targets_never_reach_the_transport_or_create_downloads() { + let fixture = Fixture::new(); + let transaction = fixture.transaction(); + let transport = ArchiveTransport::new(vec![]); + let (deadline, checkpoint) = bounded(); + for url in [ + "http://fixture.invalid/project.zip", + "https://user:secret@fixture.invalid/project.zip", + "file:///project.zip", + ] { + assert_eq!( + archive::execute( + &transport, + url, + &transaction.stage, + "generator-fixture-denied", + &super::super::marketplace_download::CancelToken::new(), + deadline, + &checkpoint + ) + .unwrap_err() + .code, + "generator.downloadFailed" + ); + } + assert!(transport.requests.lock().unwrap().is_empty()); + assert_eq!(fs::read_dir(&transaction.stage).unwrap().count(), 0); + transaction.cleanup().unwrap(); +} + +#[test] +fn archive_download_cancellation_and_https_redirect_refusal_leave_no_file() { + let fixture = Fixture::new(); + let transaction = fixture.transaction(); + let transport = ArchiveTransport::new(vec![]); + let token = super::super::marketplace_download::CancelToken::new(); + token.cancel(); + let (deadline, checkpoint) = bounded(); + assert_eq!( + archive::execute( + &transport, + "https://fixture.invalid/project.zip", + &transaction.stage, + "generator-fixture-cancelled", + &token, + deadline, + &checkpoint + ) + .unwrap_err() + .code, + "generator.cancelled" + ); + assert!(transport.requests.lock().unwrap().is_empty()); + transport.responses.lock().unwrap().clear(); + transport.responses.lock().unwrap().push_back( + super::super::marketplace_download::HttpResponse { + status: 302, + headers: HashMap::from([( + "location".into(), + "http://fixture.invalid/unsafe.zip".into(), + )]), + body: vec![], + }, + ); + assert_eq!( + archive::execute( + &transport, + "https://fixture.invalid/project.zip", + &transaction.stage, + "generator-fixture-redirect", + &super::super::marketplace_download::CancelToken::new(), + deadline, + &checkpoint + ) + .unwrap_err() + .code, + "generator.downloadFailed" + ); + assert_eq!(transport.requests.lock().unwrap().len(), 1); + assert_eq!(fs::read_dir(&transaction.stage).unwrap().count(), 0); + transaction.cleanup().unwrap(); +} + +#[test] +fn cancellation_during_private_archive_write_closes_and_removes_the_download() { + let fixture = Fixture::new(); + let transaction = fixture.transaction(); + let transport = ArchiveTransport::new(ArchiveTransport::zip(&[("file.txt", b"no")])); + let (deadline, _) = bounded(); + let checkpoint = || { + // The transaction starts empty. Cancel as soon as its exclusive ZIP + // exists, before any download bytes reach the file. + if fs::read_dir(&transaction.stage).unwrap().next().is_some() { + Err(Error::new("generator.cancelled")) + } else { + Ok(()) + } + }; + let error = archive::execute( + &transport, + "https://fixture.invalid/project.zip", + &transaction.stage, + "generator-fixture-write-cancel", + &super::super::marketplace_download::CancelToken::new(), + deadline, + &checkpoint, + ) + .unwrap_err(); + assert_eq!(error.code, "generator.cancelled"); + assert_eq!(fs::read_dir(&transaction.stage).unwrap().count(), 0); + transaction.cleanup().unwrap(); +} + +#[test] +fn expired_archive_budget_never_dispatches_http_or_creates_files() { + let fixture = Fixture::new(); + let transaction = fixture.transaction(); + let transport = ArchiveTransport::new(vec![]); + let error = archive::execute( + &transport, + "https://fixture.invalid/project.zip", + &transaction.stage, + "generator-fixture-expired", + &super::super::marketplace_download::CancelToken::new(), + Instant::now(), + &|| Ok(()), + ) + .unwrap_err(); + assert_eq!(error.code, "generator.timeout"); + assert!(transport.requests.lock().unwrap().is_empty()); + assert_eq!(fs::read_dir(&transaction.stage).unwrap().count(), 0); + transaction.cleanup().unwrap(); +} + +struct StalledArchiveTransport(Mutex>>); +impl super::super::marketplace_download::HttpTransport for StalledArchiveTransport { + fn get<'a>( + &'a self, + _: &'a str, + _: u64, + _: Instant, + _: &'a super::super::marketplace_download::CancelToken, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = std::result::Result< + super::super::marketplace_download::HttpResponse, + super::super::marketplace_download::FetchFailure, + >, + > + Send + + 'a, + >, + > { + Box::pin(async move { + if let Some(entered) = self.0.lock().unwrap().take() { + let _ = entered.send(()); + } + std::future::pending().await + }) + } +} + +#[tokio::test] +async fn native_cancel_wakes_a_pending_archive_headers_wait_and_drains_before_cleanup() { + use super::super::marketplace_download::{fetch_controlled, FetchLimits, FetchPolicy}; + let fixture = Fixture::new(); + let transaction = fixture.transaction(); + let lifecycle = super::super::lifecycle::LifecycleState::default(); + let operation = retained_operation(&lifecycle, transaction, false); + let (entered, headers) = tokio::sync::oneshot::channel(); + let transport = StalledArchiveTransport(Mutex::new(Some(entered))); + let fetch = fetch_controlled( + &transport, + "https://fixture.invalid/project.zip", + FetchLimits { + max_bytes: 32, + max_redirects: 1, + }, + FetchPolicy::https_only(), + &operation.download_cancel, + Instant::now() + Duration::from_secs(3), + ); + tokio::pin!(fetch); + let entered = tokio::time::timeout(Duration::from_secs(2), async { + tokio::select! { + entered = headers => entered.is_ok(), + _ = &mut fetch => false, + } + }) + .await; + // Cancel before any assertion; dropping this async fetch also disposes every + // pending network future, so no blocking worker can outlive this fixture. + operation.cancel(); + let result = tokio::time::timeout(Duration::from_secs(2), &mut fetch).await; + operation.cleanup_finished().unwrap(); + assert!(entered.is_ok_and(|entered| entered)); + assert!(matches!( + result.unwrap(), + Err(super::super::marketplace_download::FetchFailure::Cancelled) + )); + assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 0); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/project_generators/transaction.rs b/windows/tauri/src-tauri/src/plugin_host/project_generators/transaction.rs new file mode 100644 index 0000000000..f746a71959 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/project_generators/transaction.rs @@ -0,0 +1,337 @@ +//! Windows-owned filesystem transaction for an already validated Core plan. +use serde_json::Value; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; +use std::time::Instant; + +use super::{Error, Result}; + +const MAX_ENTRIES: usize = 20_000; +const MAX_BYTES: u64 = 512 * 1024 * 1024; +const MAX_DEPTH: usize = 64; + +pub(super) fn component(value: &str) -> Result<()> { + let base = value.split('.').next().unwrap_or("").to_ascii_uppercase(); + if value.is_empty() + || value == "." + || value == ".." + || value.ends_with(['.', ' ']) + || value + .chars() + .any(|ch| ch.is_control() || "<>:\"/\\|?*".contains(ch)) + || matches!( + base.as_str(), + "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" + ) + || ["COM", "LPT"].iter().any(|prefix| { + base.strip_prefix(prefix).is_some_and(|n| { + matches!( + n, + "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³" + ) + }) + }) + { + return Err(Error::new("generator.invalidPath")); + } + Ok(()) +} + +pub(super) fn relative(value: &str) -> Result { + if value.is_empty() || value.contains('\\') { + return Err(Error::new("generator.invalidPath")); + } + let mut path = PathBuf::new(); + for part in value.split('/') { + component(part)?; + path.push(part); + } + Ok(path) +} + +fn metadata(path: &Path) -> Result { + let info = fs::symlink_metadata(path).map_err(Error::io)?; + if info.file_type().is_symlink() { + return Err(Error::new("generator.linkRejected")); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if info.file_attributes() & 0x400 != 0 { + return Err(Error::new("generator.linkRejected")); + } + if info.is_file() { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, + }; + let file = File::open(path).map_err(Error::io)?; + let mut information = std::mem::MaybeUninit::::uninit(); + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), information.as_mut_ptr()) } + == 0 + { + return Err(Error::io(std::io::Error::last_os_error())); + } + if unsafe { information.assume_init() }.nNumberOfLinks != 1 { + return Err(Error::new("generator.linkRejected")); + } + } + } + Ok(info) +} + +pub(super) struct Parent { + pub path: PathBuf, + identity: same_file::Handle, +} +impl Parent { + pub fn open(path: &Path) -> Result { + if !path.is_absolute() { + return Err(Error::new("generator.invalidPath")); + } + let mut current = PathBuf::new(); + for part in path.components() { + match part { + Component::Prefix(_) | Component::RootDir => current.push(part.as_os_str()), + Component::Normal(_) => { + current.push(part); + metadata(¤t)?; + } + _ => return Err(Error::new("generator.invalidPath")), + } + } + if !metadata(path)?.is_dir() { + return Err(Error::new("generator.invalidPath")); + } + let path = fs::canonicalize(path).map_err(Error::io)?; + let identity = same_file::Handle::from_path(&path).map_err(Error::io)?; + Ok(Self { path, identity }) + } + pub fn check(&self) -> Result<()> { + self.reopen().map(|_| ()) + } + pub fn reopen(&self) -> Result { + let parent = Self::open(&self.path)?; + if parent.identity != self.identity { + return Err(Error::new("generator.stale")); + } + Ok(parent) + } +} + +pub(super) fn absent(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(Error::io(error)), + Ok(_) => Err(Error::new("generator.destinationExists")), + } +} + +pub(super) struct Transaction { + pub stage: PathBuf, + target: PathBuf, + parent: Parent, + identity: same_file::Handle, + published: bool, +} +impl Transaction { + pub fn create(parent: Parent, name: &str, token: &str) -> Result { + component(name)?; + component(token)?; + parent.check()?; + let target = parent.path.join(name); + absent(&target)?; + let stage = parent.path.join(format!(".{name}.lithe-creating-{token}")); + fs::create_dir(&stage).map_err(Error::io)?; + let identity = match same_file::Handle::from_path(&stage) { + Ok(identity) => identity, + Err(error) => { + fs::remove_dir(&stage).map_err(|_| Error::new("generator.cleanupFailed"))?; + return Err(Error::io(error)); + } + }; + Ok(Self { + stage, + target, + parent, + identity, + published: false, + }) + } + pub fn check(&self) -> Result<()> { + self.parent.check()?; + if !metadata(&self.stage)?.is_dir() + || same_file::Handle::from_path(&self.stage).map_err(Error::io)? != self.identity + { + return Err(Error::new("generator.stale")); + } + Ok(()) + } + pub fn write(&self, path: &str, bytes: &[u8]) -> Result<()> { + self.check()?; + let relative = relative(path)?; + let mut current = self.stage.clone(); + let parts: Vec<_> = relative.components().collect(); + for part in &parts[..parts.len() - 1] { + current.push(part); + match fs::create_dir(¤t) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + if !metadata(¤t)?.is_dir() { + return Err(Error::new("generator.invalidPath")); + } + } + Err(error) => return Err(Error::io(error)), + } + } + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(self.stage.join(relative)) + .map_err(Error::io)?; + file.write_all(bytes).map_err(Error::io)?; + file.sync_all().map_err(Error::io) + } + pub fn validate(&self, checkpoint: &dyn Fn() -> Result<()>) -> Result<()> { + self.check()?; + let mut pending = vec![(self.stage.clone(), 0)]; + let (mut count, mut bytes) = (0usize, 0u64); + while let Some((directory, depth)) = pending.pop() { + checkpoint()?; + if depth > MAX_DEPTH { + return Err(Error::new("generator.outputLimit")); + } + for entry in fs::read_dir(directory).map_err(Error::io)? { + checkpoint()?; + let entry = entry.map_err(Error::io)?; + component( + entry + .file_name() + .to_str() + .ok_or_else(|| Error::new("generator.invalidPath"))?, + )?; + count += 1; + let info = metadata(&entry.path())?; + bytes = bytes + .checked_add(info.len()) + .ok_or_else(|| Error::new("generator.outputLimit"))?; + if count > MAX_ENTRIES || bytes > MAX_BYTES { + return Err(Error::new("generator.outputLimit")); + } + if info.is_dir() { + pending.push((entry.path(), depth + 1)); + } else if !info.is_file() { + return Err(Error::new("generator.linkRejected")); + } + } + } + Ok(()) + } + pub fn collect(&self, path: &str, checkpoint: &dyn Fn() -> Result<()>) -> Result<()> { + self.validate(checkpoint)?; + let source = self.stage.join(relative(path)?); + if !metadata(&source)?.is_dir() { + return Err(Error::new("generator.invalidPath")); + } + for entry in fs::read_dir(&source).map_err(Error::io)? { + checkpoint()?; + let entry = entry.map_err(Error::io)?; + let destination = self.stage.join(entry.file_name()); + // Windows' existing no-replace primitive also handles directories. + crate::file_publication::publish_new_record(&entry.path(), &destination) + .map_err(Error::io)?; + } + fs::remove_dir(source).map_err(Error::io) + } + pub fn publish(&mut self, checkpoint: &dyn Fn() -> Result<()>) -> Result { + // All writers must have exited and the caller must validate their tree + // before taking the short cancellation/publication ordering lock. + self.check()?; + checkpoint()?; + absent(&self.target)?; + crate::file_publication::publish_new_record(&self.stage, &self.target) + .map_err(Error::io)?; + self.published = true; + Ok(self.target.clone()) + } + pub fn cleanup(&self) -> Result<()> { + if self.published { + return Ok(()); + } + self.check() + .map_err(|_| Error::new("generator.cleanupFailed"))?; + fs::remove_dir_all(&self.stage).map_err(|_| Error::new("generator.cleanupFailed"))?; + absent(&self.stage).map_err(|_| Error::new("generator.cleanupFailed")) + } +} + +pub(super) trait Executor { + fn archive(&self, _: &Value, _: &Path, _: Instant, _: &dyn Fn() -> Result<()>) -> Result<()> { + Err(Error::new("generator.archiveUnavailable")) + } + fn command( + &self, + index: usize, + step: &Value, + stage: &Path, + deadline: Instant, + checkpoint: &dyn Fn() -> Result<()>, + ) -> Result<()>; +} + +pub(super) fn execute( + transaction: &mut Transaction, + plan: &Value, + executor: &dyn Executor, + deadline: Instant, + checkpoint: &dyn Fn() -> Result<()>, +) -> Result { + let steps = plan["steps"] + .as_array() + .ok_or_else(|| Error::new("generator.invalidPlan"))?; + for (index, step) in steps.iter().enumerate() { + checkpoint()?; + transaction.check()?; + match step["kind"].as_str() { + Some("writeFiles") => { + for file in step["files"] + .as_array() + .ok_or_else(|| Error::new("generator.invalidPlan"))? + { + checkpoint()?; + transaction.write( + file["path"] + .as_str() + .ok_or_else(|| Error::new("generator.invalidPlan"))?, + file["contents"] + .as_str() + .ok_or_else(|| Error::new("generator.invalidPlan"))? + .as_bytes(), + )?; + } + } + Some("archive") => { + // A download can outlive a rename/replacement of staging. Every + // archive checkpoint must revalidate its original directory owner + // before creating the ZIP or dispatching synchronous Core writes. + let archive_checkpoint = || { + checkpoint()?; + transaction.check() + }; + executor.archive(step, &transaction.stage, deadline, &archive_checkpoint)?; + transaction.validate(checkpoint)?; + } + Some("command") => { + executor.command(index, step, &transaction.stage, deadline, checkpoint)?; + transaction.validate(checkpoint)?; + if let Some(collect) = step["collect"].as_str() { + transaction.collect(collect, checkpoint)?; + } + } + _ => return Err(Error::new("generator.invalidPlan")), + } + } + transaction.validate(checkpoint)?; + Ok(transaction.stage.clone()) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/provider.rs b/windows/tauri/src-tauri/src/plugin_host/provider.rs new file mode 100644 index 0000000000..19efc43224 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/provider.rs @@ -0,0 +1,16 @@ +//! Native build-provider session ownership. Core alone implements LSP, +//! negotiation, request framing and cancellation. The build router prepares, +//! reviews and confirms a native binding; capability rollout stays closed. + +mod host; +mod runtime; +mod session; +#[cfg(test)] +mod tests; + +pub(crate) use host::{ + authorize_shared_language, cancel_review, close_plugin, close_window, close_workspace, confirm, + core_preflight, discard_review, drain_initialization, language_start, observe_core, prepare, + remember_language_launch, set_workspace, ProviderHostState, ProviderReview, +}; +pub(crate) use session::{ProviderBinding, ProviderContext, ProviderError}; diff --git a/windows/tauri/src-tauri/src/plugin_host/provider/host.rs b/windows/tauri/src-tauri/src/plugin_host/provider/host.rs new file mode 100644 index 0000000000..aeb0eba95d --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/provider/host.rs @@ -0,0 +1,1326 @@ +//! Platform composition: M1 trust, native caller identity and writable state. + +use super::super::{launch, lifecycle::NativeLease, store, ScannedPackage}; +use super::runtime::{self, HostCore, OpenGuard}; +use super::session::{ + same_root, ProviderBinding, ProviderContext, ProviderError, Reply, Status, Transport, +}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Manager, WebviewWindow}; + +static NEXT_ID: AtomicU64 = AtomicU64::new(1); +pub(super) fn next_id() -> u64 { + NEXT_ID.fetch_add(1, Ordering::Relaxed) +} + +#[derive(Clone, PartialEq, Eq)] +struct Workspace { + root: String, + generation: u64, +} + +struct Prepared { + context: ProviderContext, + package: ScannedPackage, + plan: Value, + state_directory: PathBuf, + lease: NativeLease, + resolved: launch::ResolvedLaunch, + environment: std::collections::BTreeMap, +} + +struct LanguageLaunch { + identity: String, + resolved: Value, + review_token: Option, +} + +#[derive(Default)] +struct Registry { + workspaces: HashMap, + suspended: std::collections::HashSet, + /// Window labels are not reused after native destruction. A late start + /// can never recreate one by supplying a formerly valid workspace. + closed: std::collections::HashSet, + prepared: HashMap, + language_launches: HashMap<(String, String, String), LanguageLaunch>, + transports: HashMap>, + cleanup_aliases: HashMap, + disposed: std::collections::VecDeque<(String, String)>, +} + +#[derive(Default)] +pub(super) struct Inner { + registry: Mutex, + workspace_changes: tokio::sync::Mutex<()>, +} + +impl Inner { + fn publish( + &self, + plugin_active: impl FnOnce() -> bool, + publish: impl FnOnce(&mut Registry) -> Result, + ) -> Result { + let mut registry = self.registry.lock().expect("provider registry"); + // Mutation marks the plugin blocked before taking this registry's close + // snapshot. Checking while holding the same lock means a late owner is + // either visible to that snapshot or rejected before retaining a lease. + if !plugin_active() { + return Err(ProviderError::new( + "provider.cancelled", + "Plugin is inactive or changing version", + )); + } + publish(&mut registry) + } + + pub fn release(&self, transport: &Arc) { + let session = transport + .status + .lock() + .expect("provider session") + .session_id + .clone(); + { + let mut registry = self.registry.lock().expect("provider registry"); + registry + .cleanup_aliases + .retain(|_, owner| *owner != transport.id); + if registry.transports.remove(&transport.id).is_some() && transport.language.is_some() { + if let Some(session) = session { + if registry.disposed.len() >= 256 { + registry.disposed.pop_front(); + } + registry + .disposed + .push_back((transport.context.window.clone(), session)); + } + } + } + transport.lease.lock().expect("provider lease").take(); + transport.status.lock().expect("provider session").disposed = true; + } + + #[cfg(test)] + pub fn retain_for_test(&self, transport: Arc) { + self.registry + .lock() + .unwrap() + .transports + .insert(transport.id, transport); + } + + #[cfg(test)] + pub fn contains_for_test(&self, id: u64) -> bool { + self.registry.lock().unwrap().transports.contains_key(&id) + } +} + +#[derive(Default)] +pub(crate) struct ProviderHostState(Arc); + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProviderReview { + pub token: u64, + pub context: ProviderContext, + pub executes_project_code: bool, + pub may_use_network: bool, + /// Expanded, passive Core plan. Resolving/probing executables happens + /// only when confirm consumes this single-use native token. + pub launch_plan: Value, + pub program: launch::ResolvedLaunch, + #[serde(skip_serializing_if = "Option::is_none")] + pub invocation_plan: Option, +} + +fn inner(app: &AppHandle) -> Arc { + app.state::().0.clone() +} +fn error(detail: impl Into) -> ProviderError { + ProviderError::new("provider.unavailable", detail) +} + +fn canonical(path: &str) -> Result { + let path = Path::new(path); + if !path.is_absolute() || !path.is_dir() { + return Err(error( + "Provider root must be an existing absolute directory", + )); + } + path.canonicalize() + .map(|path| path.to_string_lossy().into_owned()) + .map_err(|e| error(e.to_string())) +} + +fn identity(package: &ScannedPackage) -> Result { + Ok(format!( + "{}:{}", + package + .root + .canonicalize() + .map_err(|e| error(e.to_string()))? + .display(), + super::super::signature::content_digest(&package.root).map_err(|e| error(e.message()))? + )) +} + +fn current(registry: &Registry, context: &ProviderContext) -> bool { + !registry.closed.contains(&context.window) + && !registry.suspended.contains(&context.window) + && registry + .workspaces + .get(&context.window) + .is_some_and(|workspace| { + workspace.root == context.workspace_root + && workspace.generation == context.generation + }) +} + +/// The native build router calls this with its own workspace generation +/// before prepare. An older generation cannot replace a newer one. Closing the +/// old scope cancels all transports before publication of a new binding. +pub(crate) async fn set_workspace( + app: &AppHandle, + window: &WebviewWindow, + workspace_root: String, + generation: u64, +) -> Result<(), ProviderError> { + let root = canonical(&workspace_root)?; + let next_root = root.clone(); + let state = inner(app); + let _workspace_change = state.workspace_changes.lock().await; + { + let mut registry = state.registry.lock().expect("provider registry"); + if registry.closed.contains(window.label()) { + return Err(error("Window was destroyed")); + } + if let Some(old) = registry.workspaces.get(window.label()) { + if old.generation > generation || (old.generation == generation && old.root != root) { + return Err(ProviderError::new( + "provider.identityChanged", + "Workspace generation is stale", + )); + } + if old.root == root + && old.generation == generation + && !registry.suspended.contains(window.label()) + { + return Ok(()); + } + // A language may start before build detection establishes its + // generation. Adopt only an unbound same-root language owner; + // never restart it merely to attach its first build binding. + if old.root == root + && old.generation == 0 + && !registry + .prepared + .values() + .any(|review| review.context.window == window.label()) + && registry + .transports + .values() + .filter(|entry| entry.context.window == window.label()) + .all(|entry| { + entry.language.is_some() + && !entry.status.lock().expect("provider session").bound + }) + { + for entry in registry + .transports + .values() + .filter(|entry| entry.context.window == window.label()) + { + entry.status.lock().expect("provider session").generation = generation; + } + registry + .workspaces + .insert(window.label().into(), Workspace { root, generation }); + return Ok(()); + } + } + registry.suspended.insert(window.label().into()); + } + // Publish the next scope only after all old processes are proven gone. + // Failed cleanup leaves the window suspended and can be retried. + close_matching(state.clone(), |context| context.window == window.label()).await?; + let mut registry = state.registry.lock().expect("provider registry"); + if registry.closed.contains(window.label()) { + return Err(error("Window was destroyed")); + } + registry.workspaces.insert( + window.label().into(), + Workspace { + root: next_root, + generation, + }, + ); + registry.suspended.remove(window.label()); + Ok(()) +} + +/// Passive preparation. The injected native window owns the review; neither +/// a session ID nor another window's label is accepted from the webview. +pub(crate) fn prepare( + app: &AppHandle, + window: &WebviewWindow, + workspace_root: String, + generation: u64, + plugin_id: String, + build_system: String, + instance_root: String, + runtimes: std::collections::BTreeMap< + String, + super::super::build_resolver::RuntimeExecutableInput, + >, +) -> Result { + let launch_workspace_root = workspace_root.clone(); + let workspace_root = canonical(&workspace_root)?; + let instance_directory = + super::super::build_resolver::WindowsBuildToolResolver::instance_root_directory( + &instance_root, + Path::new(&workspace_root), + ) + .map_err(|error| ProviderError::new("provider.invalidInstance", error.to_string()))? + .to_string_lossy() + .into_owned(); + let lease = app + .state::() + .acquire(&plugin_id) + .map_err(error)?; + let package = launch::verified_package_by_id(app, &plugin_id).map_err(error)?; + let context = ProviderContext { + window: window.label().into(), + workspace_root, + launch_workspace_root, + generation, + plugin_id, + package_identity: identity(&package)?, + build_system, + instance_root, + instance_directory, + }; + let state_directory = store::plugin_state_directory(app, &context.plugin_id).map_err(error)?; + let plan = plan(app, &context, &package, &state_directory)?; + let mut environment: std::collections::BTreeMap = std::env::vars().collect(); + for (id, runtime) in runtimes { + launch::merge_environment(&mut environment, runtime.environment); + launch::merge_environment( + &mut environment, + std::collections::BTreeMap::from([( + format!("LITHE_{}_PATH", id.to_uppercase().replace('-', "_")), + runtime.executable.to_string_lossy().into_owned(), + )]), + ); + } + let launch_id = plan["buildProvider"]["languageServer"] + .as_str() + .unwrap_or(&context.build_system); + let resolved = launch::resolve_launch_passive_with_environment( + &package, + launch_id, + &plan, + &state_directory, + &environment, + ) + .map_err(error)?; + let token = next_id(); + let review = ProviderReview { + token, + context: context.clone(), + executes_project_code: plan["buildProvider"]["executesProjectCode"] + .as_bool() + .unwrap_or(true), + may_use_network: plan["buildProvider"]["mayUseNetwork"] + .as_bool() + .unwrap_or(true), + launch_plan: plan.clone(), + program: resolved.clone(), + invocation_plan: None, + }; + let state = inner(app); + state.publish( + || { + !app.state::() + .blocked(&review.context.plugin_id) + }, + |registry| { + if !current(registry, &context) { + return Err(ProviderError::new( + "provider.identityChanged", + "Workspace changed", + )); + } + // Bound abandoned reviews; cancellation/close also removes them. + if registry.prepared.len() >= 64 { + return Err(error("Too many pending provider reviews")); + } + registry.prepared.insert( + token, + Prepared { + context, + package, + plan, + state_directory, + lease, + resolved, + environment, + }, + ); + Ok(()) + }, + )?; + Ok(review) +} + +pub(crate) fn cancel_review( + app: &AppHandle, + window: &WebviewWindow, + token: u64, +) -> Result<(), ProviderError> { + let state = inner(app); + let mut registry = state.registry.lock().expect("provider registry"); + if let Some(review) = registry.prepared.get(&token) { + if review.context.window != window.label() { + return Err(error("Provider review belongs to another window")); + } + } + registry.prepared.remove(&token); + Ok(()) +} + +/// A build operation retains this single-use review until confirmation or +/// cancellation. Removing it does not stop another binding's shared transport. +pub(crate) fn discard_review(app: &AppHandle, window: &str, token: u64) { + let state = inner(app); + let mut registry = state.registry.lock().expect("provider registry"); + if registry + .prepared + .get(&token) + .is_some_and(|review| review.context.window == window) + { + registry.prepared.remove(&token); + } +} + +/// The build owner calls this after approval, before asking the existing editor +/// owner to ensure its language session. Passive preparation issues no ticket. +pub(crate) fn authorize_shared_language( + app: &AppHandle, + window: &str, + token: u64, + checkpoint: impl Fn() -> Result<(), String>, +) -> Result<(), ProviderError> { + let state = inner(app); + let (context, expected, program, environment, directory) = { + let registry = state.registry.lock().expect("provider registry"); + let prepared = registry + .prepared + .get(&token) + .ok_or_else(|| error("Provider review was cancelled"))?; + if prepared.context.window != window || !current(®istry, &prepared.context) { + return Err(error("Provider owner changed")); + } + if !prepared.plan["buildProvider"]["languageServer"].is_string() { + return Ok(()); + } + ( + prepared.context.clone(), + prepared.plan.clone(), + prepared.resolved.clone(), + prepared.environment.clone(), + prepared.state_directory.clone(), + ) + }; + let _lease = app + .state::() + .acquire(&context.plugin_id) + .map_err(error)?; + let package = launch::verified_package_by_id(app, &context.plugin_id).map_err(error)?; + if identity(&package)? != context.package_identity + || plan(app, &context, &package, &directory)? != expected + { + return Err(ProviderError::new( + "provider.identityChanged", + "Provider approval is stale", + )); + } + let resolved = launch::resolve_launch_passive_with_environment( + &package, + &program.language_id, + &expected, + &directory, + &environment, + ) + .map_err(error)?; + if resolved != program { + return Err(error("Provider program selection changed")); + } + let workspace = directory.join(store::workspace_state_key(&context.launch_workspace_root)); + launch::prepare_required_directories(&expected, &directory, &workspace).map_err(error)?; + std::fs::create_dir_all(&directory).map_err(|failure| error(failure.to_string()))?; + launch::validate_resolved_launch_checked(&expected, &program, &directory, &checkpoint) + .map_err(error)?; + checkpoint().map_err(|detail| ProviderError::new("provider.cancelled", detail))?; + remember_language_launch(app, window, &context.workspace_root, &package, &program) + .map_err(error)?; + let mut registry = state.registry.lock().expect("provider registry"); + if !registry.prepared.contains_key(&token) { + return Err(error("Provider review was cancelled")); + } + if let Some(launch) = registry.language_launches.get_mut(&( + window.into(), + context.workspace_root, + program.language_id, + )) { + launch.review_token = Some(token); + } + Ok(()) +} + +/// Wait for a cancelled sole dedicated initializer's cleanup. Reused and +/// shared language owners belong to remaining users and are not stopped. +pub(crate) async fn drain_initialization( + app: &AppHandle, + window: &str, + token: u64, +) -> Result<(), ProviderError> { + let state = inner(app); + let entry = { + let registry = state.registry.lock().expect("provider registry"); + registry + .transports + .get(registry.cleanup_aliases.get(&token).unwrap_or(&token)) + .cloned() + }; + if let Some(entry) = entry { + let stop = { + let status = entry.status.lock().expect("provider session"); + entry.context.window == window + && entry.language.is_none() + && (status.revoked || (status.open_waiters == 0 && !status.bound)) + }; + if stop { + runtime::stop(state, entry).await?; + } + } + Ok(()) +} + +fn plan( + app: &AppHandle, + context: &ProviderContext, + package: &ScannedPackage, + state: &Path, +) -> Reply { + let layers = super::super::settings::load_layers( + app, + &context.plugin_id, + &context.launch_workspace_root, + ) + .map_err(error)?; + let result = super::super::scan::core_execute( + "plugins.declarative.languageServerLaunchPlan", + json!({ + "manifest":package.manifest, "buildSystemID":context.build_system, + "request":launch::launch_request(state, package, &context.launch_workspace_root, layers), + }), + ) + .map_err(error)?; + if result["plan"].is_null() || result["status"] == "rejected" { + return Err(error(result.to_string())); + } + Ok(result["plan"].clone()) +} + +fn transport( + id: u64, + context: ProviderContext, + language: Option, + launch_plan: Option, + lease: NativeLease, +) -> Arc { + let generation = context.generation; + Arc::new(Transport { + id, + context, + language, + launch_plan, + status: Mutex::new(Status { + generation, + ..Status::default() + }), + changed: Default::default(), + stopping: Default::default(), + core: Arc::new(HostCore), + lease: Mutex::new(Some(lease)), + }) +} + +/// Explicit consent entry, called only after the product presents ProviderReview. +/// Build commands consume this binding only after their native review is approved. +pub(crate) async fn confirm( + app: &AppHandle, + window: &WebviewWindow, + token: u64, +) -> Result { + let state = inner(app); + let prepared = { + let mut registry = state.registry.lock().expect("provider registry"); + let review = registry + .prepared + .get(&token) + .ok_or_else(|| error("Unknown provider review"))?; + if review.context.window != window.label() { + return Err(error("Provider review belongs to another window")); + } + if !current(®istry, &review.context) { + return Err(error("Workspace changed")); + } + registry.prepared.remove(&token).expect("checked review") + }; + let verify_app = app.clone(); + let prepared = tauri::async_runtime::spawn_blocking(move || { + let app = &verify_app; + let context = &prepared.context; + let package = launch::verified_package_by_id(app, &context.plugin_id).map_err(error)?; + if identity(&package)? != context.package_identity + || plan(app, context, &package, &prepared.state_directory)? != prepared.plan + { + return Err(ProviderError::new( + "provider.identityChanged", + "Package or settings changed since review", + )); + } + let selected = launch::resolve_launch_passive_with_environment( + &package, + prepared.plan["buildProvider"]["languageServer"] + .as_str() + .unwrap_or(&context.build_system), + &prepared.plan, + &prepared.state_directory, + &prepared.environment, + ) + .map_err(error)?; + if selected != prepared.resolved { + return Err(error( + "Provider executable selection changed since approval", + )); + } + + Ok::<_, ProviderError>(prepared) + }) + .await + .map_err(|error| ProviderError::new("provider.workerFailed", error.to_string()))??; + let context = prepared.context.clone(); + if let Some(language) = prepared.plan["buildProvider"]["languageServer"].as_str() { + let selected = { + let registry = state.registry.lock().expect("provider registry"); + if !current(®istry, &context) { + return Err(error("Workspace changed")); + } + let candidates: Vec<_> = registry + .transports + .values() + .filter(|entry| { + entry.matches_shared(&context, language) + && entry.launch_plan.as_ref() + == serde_json::to_value(&prepared.resolved).ok().as_ref() + }) + .cloned() + .collect(); + if candidates.len() != 1 { + return Err(error("No unique native same-plugin language session")); + } + candidates[0].clone() + }; + // The language owner keeps its lease. No probe or second process is + // started for the shared source, and cancellation preserves the editor. + runtime::ready(&selected).await?; + selected.status.lock().expect("provider session").bound = true; + let binding = ProviderBinding { + transport: selected, + approval_plan: prepared.plan.clone(), + context, + }; + validate_binding_async(app, window.label(), &binding).await?; + return Ok(binding); + } + let candidate = transport( + token, + context.clone(), + None, + Some(prepared.plan.clone()), + prepared.lease, + ); + // Reap a crashed or settings-invalid dedicated owner before replacement. + // Positive stop proof is mandatory; a failed stop retains the old owner. + let previous: Vec<_> = state + .registry + .lock() + .expect("provider registry") + .transports + .values() + .filter(|entry| { + entry.language.is_none() + && entry.matches_owner(&context) + && entry.context.build_system == context.build_system + && (entry.status.lock().expect("provider session").revoked + || entry.launch_plan != candidate.launch_plan) + }) + .cloned() + .collect(); + for old in previous { + { + let mut registry = state.registry.lock().expect("provider registry"); + if registry.cleanup_aliases.len() >= 256 { + return Err(error("Too many failed provider cleanup reviews")); + } + registry.cleanup_aliases.insert(token, old.id); + } + runtime::stop(state.clone(), old).await?; + } + let entry = state.publish( + || { + !app.state::() + .blocked(&context.plugin_id) + }, + |registry| { + if !current(registry, &context) { + return Err(error("Workspace changed")); + } + if let Some(existing) = registry.transports.values().find(|entry| { + entry.language.is_none() + && entry.matches_owner(&context) + && entry.context.build_system == context.build_system + }) { + if existing.launch_plan != candidate.launch_plan { + return Err(error( + "Provider settings changed; close the native session before restarting", + )); + } + Ok(existing.clone()) + } else { + if registry.transports.len() >= 64 { + return Err(error("Too many native provider sessions")); + } + registry.transports.insert(token, candidate.clone()); + Ok(candidate) + } + }, + )?; + if entry.id != token { + // One build-system transport serves sibling instance roots. The + // binding still validates each request's exact Core-planned root. + let mut guard = OpenGuard::new(state.clone(), entry.clone()); + runtime::ready(&entry).await?; + let binding = ProviderBinding { + transport: entry, + approval_plan: prepared.plan.clone(), + context, + }; + validate_binding_async(app, window.label(), &binding).await?; + guard.commit(); + return Ok(binding); + } + let approval_plan = prepared.plan.clone(); + let mut guard = OpenGuard::new(state.clone(), entry.clone()); + let worker_entry = entry.clone(); + let worker_app = app.clone(); + let worker_state = state.clone(); + // Cancellation does not discard this worker: it retains the native owner + // until the late start returns and the process tree has been disposed. + tauri::async_runtime::spawn_blocking(move || { + let result = (|| { + let package = + launch::verified_package_by_id(&worker_app, &context.plugin_id).map_err(error)?; + if identity(&package)? != context.package_identity { + return Err(error("Package changed")); + } + if worker_entry + .status + .lock() + .expect("provider session") + .revoked + { + return Err(error("Provider start cancelled")); + } + let workspace_state = prepared + .state_directory + .join(store::workspace_state_key(&context.launch_workspace_root)); + // Both state directories are host app-data paths, never package + // resources. Their per-plugin/workspace lifetime follows store.rs; + // writes cannot affect signatures or update package baselines. + launch::prepare_required_directories( + &prepared.plan, + &prepared.state_directory, + &workspace_state, + ) + .map_err(error)?; + std::fs::create_dir_all(&prepared.state_directory).map_err(|e| error(e.to_string()))?; + let resolved = prepared.resolved; + launch::validate_resolved_launch_checked( + &prepared.plan, + &resolved, + &prepared.state_directory, + || { + if worker_entry + .status + .lock() + .expect("provider session") + .revoked + || worker_app + .state::() + .blocked(&context.plugin_id) + { + Err("provider.cancelled".into()) + } else { + Ok(()) + } + }, + ) + .map_err(error)?; + if worker_entry + .status + .lock() + .expect("provider session") + .revoked + { + return Err(error("Provider start cancelled")); + } + if identity( + &launch::verified_package_by_id(&worker_app, &context.plugin_id).map_err(error)?, + )? != context.package_identity + { + return Err(error("Package changed during runtime resolution")); + } + if plan(&worker_app, &context, &package, &prepared.state_directory)? != prepared.plan { + return Err(error("Provider settings changed during runtime resolution")); + } + let mut environment = prepared.environment; + launch::merge_environment(&mut environment, resolved.environment); + worker_entry.core.start(json!({ + "providerId":format!("buildProvider:{}:{}",context.plugin_id,context.build_system), + "pluginId":context.plugin_id, "executablePath":resolved.server_path, "arguments":resolved.server_args, + "environment":environment, "workingDirectory":context.workspace_root, + "rootUri":url::Url::from_directory_path(&context.workspace_root).map_err(|_| error("Invalid workspace URI"))?.as_str(), + "initializationOptions":resolved.initialization_options,"workspaceConfiguration":resolved.workspace_configuration, + "initializeTimeoutMilliseconds":10000,"requestTimeoutMilliseconds":30000,"shutdownTimeoutMilliseconds":2000, + }), &|id| worker_entry.status.lock().expect("provider session").session_id = Some(id.into())) + })(); + worker_entry + .status + .lock() + .expect("provider session") + .launch_finished = true; + if let Err(error) = result { + worker_entry.revoke(error); + } + worker_entry.changed.notify_waiters(); + runtime::pump(worker_state, worker_entry); + }); + runtime::ready(&entry).await?; + let binding = ProviderBinding { + transport: entry, + approval_plan, + context: prepared.context, + }; + validate_binding_async(app, window.label(), &binding).await?; + guard.commit(); + Ok(binding) +} + +pub(super) fn validate_binding( + app: &AppHandle, + window: &str, + binding: &ProviderBinding, +) -> Result<(), ProviderError> { + let context = &binding.context; + if context.window != window + || app.get_webview_window(window).is_none() + || !current( + &inner(app).registry.lock().expect("provider registry"), + context, + ) + || binding + .transport + .status + .lock() + .expect("provider session") + .revoked + { + return Err(ProviderError::new( + "provider.identityChanged", + "Provider owner is no longer current", + )); + } + let package = launch::verified_package_by_id(app, &context.plugin_id).map_err(error)?; + let directory = + super::super::build_resolver::WindowsBuildToolResolver::instance_root_directory( + &context.instance_root, + Path::new(&context.workspace_root), + ) + .map_err(|error| ProviderError::new("provider.invalidInstance", error.to_string()))?; + if !same_root(&directory.to_string_lossy(), &context.instance_directory) { + return Err(error("Instance directory identity changed")); + } + if identity(&package)? != context.package_identity { + return Err(error("Provider package changed")); + } + let state = store::plugin_state_directory(app, &context.plugin_id).map_err(error)?; + if plan(app, context, &package, &state)? != binding.approval_plan { + return Err(ProviderError::new( + "provider.identityChanged", + "Provider settings changed", + )); + } + Ok(()) +} + +pub(super) async fn validate_binding_async( + app: &AppHandle, + window: &str, + binding: &ProviderBinding, +) -> Result<(), ProviderError> { + let app = app.clone(); + let window = window.to_owned(); + let binding = binding.clone(); + tauri::async_runtime::spawn_blocking(move || validate_binding(&app, &window, &binding)) + .await + .map_err(|error| ProviderError::new("provider.workerFailed", error.to_string()))? +} + +async fn close_matching( + state: Arc, + predicate: impl Fn(&ProviderContext) -> bool, +) -> Result<(), ProviderError> { + let entries = { + let mut registry = state.registry.lock().expect("provider registry"); + registry + .prepared + .retain(|_, review| !predicate(&review.context)); + let entries: Vec<_> = registry + .transports + .values() + .filter(|entry| predicate(&entry.context)) + .cloned() + .collect(); + for entry in &entries { + entry.revoke(ProviderError::new( + "provider.cancelled", + "Native scope revoked", + )); + } + entries + }; + // Stop independent owners concurrently so one scope has a bounded drain, + // rather than multiplying the stop deadline by its number of processes. + let tasks: Vec<_> = entries + .into_iter() + .map(|entry| { + let state = state.clone(); + tauri::async_runtime::spawn(async move { runtime::stop(state, entry).await }) + }) + .collect(); + let mut failure = None; + for task in tasks { + match task.await { + Ok(Ok(())) => {} + Ok(Err(error)) => failure = Some(error), + Err(join) => failure = Some(error(join.to_string())), + } + } + failure.map_or(Ok(()), Err) +} + +pub(crate) async fn close_plugin(app: &AppHandle, plugin: &str) -> Result<(), ProviderError> { + inner(app) + .registry + .lock() + .expect("provider registry") + .language_launches + .retain(|_, approval| approval.resolved["pluginId"] != plugin); + close_matching(inner(app), |context| context.plugin_id == plugin).await +} +pub(crate) async fn close_window(app: &AppHandle, window: &str) -> Result<(), ProviderError> { + let state = inner(app); + { + let mut registry = state.registry.lock().expect("provider registry"); + registry.closed.insert(window.into()); + registry.workspaces.remove(window); + registry + .language_launches + .retain(|(owner, _, _), _| owner != window); + } + close_matching(state, |context| context.window == window).await +} +pub(crate) async fn close_workspace( + app: &AppHandle, + window: &str, + root: &str, +) -> Result<(), ProviderError> { + let state = inner(app); + // A workspace may have been deleted before close. Prefer the canonical + // identity, but never prevent cleanup merely because the directory is gone. + let root = canonical(root).unwrap_or_else(|_| root.to_owned()); + { + let mut registry = state.registry.lock().expect("provider registry"); + if registry + .workspaces + .get(window) + .is_some_and(|workspace| same_root(&workspace.root, &root)) + { + registry.workspaces.remove(window); + registry.suspended.insert(window.into()); + } + registry + .language_launches + .retain(|(owner, workspace, _), _| owner != window || !same_root(workspace, &root)); + } + close_matching(state, |context| { + context.window == window && same_root(&context.workspace_root, &root) + }) + .await +} + +/// Called only by the native Core bridge, before a plugin language start. +/// The caller window, package digest, provider/language and canonical root are +/// recorded natively; a later build review can only bind this exact source. +pub(crate) fn language_start(app: &AppHandle, window: &str, payload: &Value) -> Option { + let plugin = payload["pluginId"].as_str()?; + let candidate = match app + .state::() + .packages() + .into_iter() + .find(|package| package.entry.plugin_id == plugin) + { + Some(package) => package, + None => { + return Some(Err(error( + "Plugin language start has no native package record", + ))) + } + }; + // This adapter owns languages of plugins that explicitly share a server + // with a build provider. + // Other existing language start paths retain their current composition. + if !candidate.manifest["buildSystems"] + .as_array() + .is_some_and(|systems| { + systems + .iter() + .any(|system| system["provider"]["languageServer"].is_string()) + }) + { + return None; + } + Some((|| { + let lease = app + .state::() + .acquire(plugin) + .map_err(error)?; + let package = launch::verified_package_by_id(app, plugin).map_err(error)?; + let root = canonical(payload["workingDirectory"].as_str().unwrap_or_default())?; + let uri_root = url::Url::parse(payload["rootUri"].as_str().unwrap_or_default()) + .ok() + .and_then(|uri| uri.to_file_path().ok()) + .and_then(|path| path.canonicalize().ok()); + if uri_root.as_deref() != Some(Path::new(&root)) { + return Err(error("Language root URI does not match its workspace")); + } + let state = inner(app); + let entry = state.publish( + || { + !app.state::() + .blocked(plugin) + }, + |registry| { + if registry.closed.contains(window) || app.get_webview_window(window).is_none() { + return Err(error("Window closed")); + } + if registry.suspended.contains(window) { + return Err(error("Workspace is stopping its old native sessions")); + } + // The editor uses pluginId as providerId, not languageID. Recover + // the language only from the native launch ticket, never that field. + let matching: Vec<_> = registry + .language_launches + .iter() + .filter(|((owner, workspace, _), approval)| { + owner == window + && workspace == &root + && matches_language_launch(&approval.resolved, payload) + && approval + .review_token + .is_none_or(|token| registry.prepared.contains_key(&token)) + }) + .map(|(key, _)| key.clone()) + .collect(); + if matching.len() != 1 { + return Err(error( + "Language start needs one unambiguous native launch approval", + )); + } + let key = matching[0].clone(); + let language = key.2.clone(); + let approved = registry + .language_launches + .remove(&key) + .expect("checked launch approval"); + if !package.manifest["languageServers"] + .as_array() + .is_some_and(|servers| { + servers + .iter() + .any(|server| server["languageID"] == language) + }) + { + return Err(error("Language session is not declared by its plugin")); + } + if approved.identity != identity(&package)? + || !matches_language_launch(&approved.resolved, payload) + { + return Err(error( + "Language start differs from its verified native launch", + )); + } + // Existing language starts predate build routing. A later native + // build router must adopt this generation via set_workspace. + let workspace = registry + .workspaces + .entry(window.into()) + .or_insert(Workspace { + root: root.clone(), + generation: 0, + }); + if workspace.root != root { + return Err(error( + "Workspace changed; native owner must advance its generation", + )); + } + let context = ProviderContext { + window: window.into(), + workspace_root: root.clone(), + launch_workspace_root: payload["workingDirectory"] + .as_str() + .unwrap_or_default() + .into(), + generation: workspace.generation, + plugin_id: plugin.into(), + package_identity: identity(&package)?, + build_system: String::new(), + instance_root: ".".into(), + instance_directory: root, + }; + if registry.transports.len() >= 64 { + return Err(error("Too many native provider sessions")); + } + let entry = transport( + next_id(), + context, + Some(language.into()), + Some(approved.resolved), + lease, + ); + registry.transports.insert(entry.id, entry.clone()); + Ok(entry) + }, + )?; + let result = entry.core.start(payload.clone(), &|id| { + entry.status.lock().expect("provider session").session_id = Some(id.into()) + }); + entry + .status + .lock() + .expect("provider session") + .launch_finished = true; + if let Err(error) = &result { + entry.revoke(error.clone()); + runtime::cleanup_later(state, entry.clone()); + } + result + })()) +} + +pub(crate) fn remember_language_launch( + app: &AppHandle, + window: &str, + root: &str, + package: &ScannedPackage, + resolved: &launch::ResolvedLaunch, +) -> Result<(), String> { + if !package.manifest["buildSystems"] + .as_array() + .is_some_and(|systems| { + systems + .iter() + .any(|system| system["provider"]["languageServer"].is_string()) + }) + { + return Ok(()); + } + let root = canonical(root).map_err(|error| error.detail)?; + let approved = LanguageLaunch { + identity: identity(package).map_err(|error| error.detail)?, + resolved: serde_json::to_value(resolved).map_err(|error| error.to_string())?, + review_token: None, + }; + let state = inner(app); + state + .publish( + || { + !app.state::() + .blocked(&package.entry.plugin_id) + }, + |registry| { + if registry.closed.contains(window) { + return Err(error("Language owner window closed")); + } + if registry.suspended.contains(window) { + return Err(error("Language owner workspace is closed or stopping")); + } + if registry.language_launches.len() >= 64 { + return Err(error("Too many pending language launch approvals")); + } + registry.language_launches.insert( + (window.into(), root, resolved.language_id.clone()), + approved, + ); + Ok(()) + }, + ) + .map_err(|error| error.detail) +} + +pub(super) fn matches_language_launch(approved: &Value, payload: &Value) -> bool { + approved["pluginId"] == payload["pluginId"] + && approved["pluginId"] == payload["providerId"] + && approved["serverPath"] == payload["executablePath"] + && approved["serverArgs"] == payload["arguments"] + && approved["initializationOptions"] == payload["initializationOptions"] + && approved["workspaceConfiguration"] == payload["workspaceConfiguration"] + && approved["environment"] == payload["environment"] + && [ + "runtimeExecutablePath", + "jdtlsLaunchResources", + "cacheDirectory", + "workspaceFingerprint", + "mavenContext", + "javaGradle", + ] + .iter() + .all(|key| payload[*key].is_null()) + && ["hostServices", "javaRuntimes"].iter().all(|key| { + payload[*key].is_null() || payload[*key].as_array().is_some_and(Vec::is_empty) + }) +} + +/// Shared language cleanup can finish before the editor acknowledges a plugin +/// mutation. Recognize its later stop/destroy without releasing an unverified +/// owner, and reject foreign-window or direct webview build requests. +pub(crate) fn core_preflight( + app: &AppHandle, + window: &str, + command: &str, + payload: &Value, +) -> Option { + preflight(&inner(app), window, command, payload) +} + +pub(super) fn preflight( + state: &Inner, + window: &str, + command: &str, + payload: &Value, +) -> Option { + if !command.starts_with("lsp.") { + return None; + } + let session = payload["sessionId"].as_str()?; + let registry = state.registry.lock().expect("provider registry"); + if let Some((owner, _)) = registry.disposed.iter().find(|(_, id)| id == session) { + if owner != window { + return Some(Err(error("Session belongs to another native window"))); + } + if matches!(command, "lsp.stopServer" | "lsp.destroyServer") { + return Some(Ok(json!({}))); + } + } + for transport in registry.transports.values() { + if transport + .status + .lock() + .expect("provider session") + .session_id + .as_deref() + == Some(session) + && (transport.context.window != window + || transport.language.is_none() + || command == "lsp.buildRequest") + { + return Some(Err(error( + "Provider request requires its native approved binding", + ))); + } + } + None +} + +/// Mirrors an existing event consumer's response. It never drains a shared +/// language queue. On successful destroy, Core has already verified disposal. +pub(crate) fn observe_core( + app: &AppHandle, + window: &str, + command: &str, + payload: &Value, + response: &Value, +) { + let state = inner(app); + let entries: Vec<_> = state + .registry + .lock() + .expect("provider registry") + .transports + .values() + .filter(|entry| { + entry.context.window == window + && entry.language.is_some() + && entry + .status + .lock() + .expect("provider session") + .session_id + .as_deref() + == payload["sessionId"].as_str() + }) + .cloned() + .collect(); + for entry in entries { + if response["ok"] != true { + continue; + } + if matches!(command, "lsp.pollEvents" | "lsp.waitEvents") { + for event in response["data"]["events"].as_array().into_iter().flatten() { + entry.observe(event); + } + } + if command == "lsp.destroyServer" { + entry.revoke(ProviderError::new( + "provider.sessionEnded", + "Language owner destroyed the session", + )); + state.release(&entry); + } else if entry.status.lock().expect("provider session").revoked { + runtime::cleanup_later(state.clone(), entry.clone()); + } + } +} + +#[cfg(test)] +#[path = "host_tests.rs"] +mod tests; diff --git a/windows/tauri/src-tauri/src/plugin_host/provider/host_tests.rs b/windows/tauri/src-tauri/src/plugin_host/provider/host_tests.rs new file mode 100644 index 0000000000..db3907ea96 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/provider/host_tests.rs @@ -0,0 +1,222 @@ +//! Publication and mutation ordering without app windows or real processes. + +use super::*; +use crate::plugin_host::{lifecycle::LifecycleState, CatalogEntry}; +use std::sync::atomic::AtomicBool; +use std::time::Duration; +use tokio::sync::oneshot; + +const DEADLINE: Duration = Duration::from_secs(2); +const PLUGIN: &str = "fixture.provider"; + +#[derive(Default)] +struct CleanupCore(Mutex>); + +impl super::super::session::Core for CleanupCore { + fn execute(&self, command: &str, _payload: Value) -> Reply { + self.0.lock().unwrap().push(command.into()); + Ok(json!({})) + } + + fn start(&self, _payload: Value, _registered: &dyn Fn(&str)) -> Reply { + panic!("a rejected owner must never launch a process") + } +} + +fn context() -> ProviderContext { + ProviderContext { + window: "workspace-fixture".into(), + workspace_root: "/fixture/workspace".into(), + launch_workspace_root: "/fixture/workspace".into(), + generation: 1, + plugin_id: PLUGIN.into(), + package_identity: "fixture:package-digest".into(), + build_system: "fixture-build".into(), + instance_root: ".".into(), + instance_directory: "/fixture/workspace".into(), + } +} + +fn prepared(lease: NativeLease) -> Prepared { + Prepared { + context: context(), + package: ScannedPackage { + entry: CatalogEntry { + plugin_id: PLUGIN.into(), + display_name: "Fixture".into(), + version: "1.0.0".into(), + api_version: 3, + source: "installed", + enabled: true, + status: "accepted", + languages: Vec::new(), + declared_settings: Vec::new(), + declares_tests: false, + primary_language_id: None, + rejection: None, + }, + root: PathBuf::from("/fixture/plugin"), + manifest: json!({}), + trust: None, + }, + plan: json!({}), + state_directory: PathBuf::from("/fixture/state"), + lease, + environment: Default::default(), + resolved: launch::ResolvedLaunch { + plugin_id: PLUGIN.into(), + language_id: "fixture-build".into(), + server_path: "/fixture/provider".into(), + server_args: Vec::new(), + initialization_options: None, + workspace_configuration: None, + environment: Default::default(), + }, + } +} + +fn native_transport(lease: NativeLease, shared: bool, core: Arc) -> Arc { + let mut entry = transport( + 1, + context(), + shared.then(|| "fixture-language".into()), + None, + lease, + ); + Arc::get_mut(&mut entry).unwrap().core = core; + *entry.status.lock().unwrap() = Status { + generation: 1, + session_id: Some("fixture-session".into()), + launch_finished: true, + ..Default::default() + }; + entry +} + +enum PendingOwner { + Review(Prepared), + Transport(Arc), +} + +impl PendingOwner { + fn publish(self, registry: &mut Registry) -> Result<(), ProviderError> { + match self { + Self::Review(review) => { + registry.prepared.insert(1, review); + } + Self::Transport(transport) => { + registry.transports.insert(transport.id, transport); + } + } + Ok(()) + } +} + +async fn rejects_publication_after_mutation_snapshot(owner: PendingOwner) { + let state = Arc::new(Inner::default()); + let active = AtomicBool::new(true); + let (reached, reached_result) = oneshot::channel(); + let (resume, resumed) = oneshot::channel(); + let pending = async { + // The owner already holds its package lease and verified inputs, but + // has not published the persistent review/session to the close registry. + let owner = owner; + reached.send(()).unwrap(); + resumed + .await + .map_err(|_| error("Test publication was cancelled"))?; + state.publish( + || active.load(Ordering::Acquire), + |registry| owner.publish(registry), + ) + }; + tokio::pin!(pending); + tokio::time::timeout(DEADLINE, async { + tokio::select! { biased; + result = &mut pending => panic!("publication passed its closed gate: {result:?}"), + result = reached_result => result.expect("publication reached its gate"), + } + }) + .await + .expect("publication reached its gate within the test deadline"); + + // Match lifecycle.begin's order: revoke new owners, then take the close + // snapshot while this in-flight preparation is still absent from it. + active.store(false, Ordering::Release); + tokio::time::timeout( + DEADLINE, + close_matching(state.clone(), |context| context.plugin_id == PLUGIN), + ) + .await + .expect("empty close snapshot completed") + .unwrap(); + resume.send(()).unwrap(); + let result = tokio::time::timeout(DEADLINE, &mut pending) + .await + .expect("revoked publication completed"); + assert_eq!(result.unwrap_err().code, "provider.cancelled"); + let registry = state.registry.lock().unwrap(); + assert!(registry.prepared.is_empty()); + assert!(registry.transports.is_empty()); +} + +#[tokio::test] +async fn pending_review_cannot_publish_after_mutation_close_snapshot() { + let lifecycle = LifecycleState::default(); + let review = prepared(lifecycle.acquire(PLUGIN).unwrap()); + rejects_publication_after_mutation_snapshot(PendingOwner::Review(review)).await; +} + +#[tokio::test] +async fn shared_language_owner_cannot_publish_after_mutation_close_snapshot() { + let lifecycle = LifecycleState::default(); + let core = Arc::new(CleanupCore::default()); + let entry = native_transport(lifecycle.acquire(PLUGIN).unwrap(), true, core.clone()); + let weak = Arc::downgrade(&entry); + rejects_publication_after_mutation_snapshot(PendingOwner::Transport(entry)).await; + assert!( + weak.upgrade().is_none(), + "rejection released its native owner and lease" + ); + assert!(core.0.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn dedicated_owner_cannot_publish_after_mutation_close_snapshot() { + let lifecycle = LifecycleState::default(); + let entry = native_transport(lifecycle.acquire(PLUGIN).unwrap(), false, Arc::default()); + let weak = Arc::downgrade(&entry); + rejects_publication_after_mutation_snapshot(PendingOwner::Transport(entry)).await; + assert!(weak.upgrade().is_none()); +} + +#[tokio::test] +async fn owner_published_before_revocation_is_drained_by_mutation_snapshot() { + let lifecycle = LifecycleState::default(); + let state = Arc::new(Inner::default()); + let active = AtomicBool::new(true); + let core = Arc::new(CleanupCore::default()); + let entry = native_transport(lifecycle.acquire(PLUGIN).unwrap(), true, core.clone()); + state + .publish( + || active.load(Ordering::Acquire), + |registry| PendingOwner::Transport(entry.clone()).publish(registry), + ) + .unwrap(); + + active.store(false, Ordering::Release); + tokio::time::timeout( + DEADLINE, + close_matching(state.clone(), |context| context.plugin_id == PLUGIN), + ) + .await + .expect("published owner completed bounded cleanup") + .unwrap(); + assert!(entry.status.lock().unwrap().revoked); + assert!(entry.lease.lock().unwrap().is_none()); + assert!(!state.contains_for_test(entry.id)); + assert_eq!( + *core.0.lock().unwrap(), + vec!["lsp.stopServer", "lsp.destroyServer"] + ); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/provider/runtime.rs b/windows/tauri/src-tauri/src/plugin_host/provider/runtime.rs new file mode 100644 index 0000000000..606bd704f6 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/provider/runtime.rs @@ -0,0 +1,324 @@ +//! Bounded native orchestration around Core's existing transport owner. + +use super::host::Inner; +use super::session::{Core, ProviderBinding, ProviderError, Reply, Transport}; +use serde_json::{json, Value}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +pub(super) struct HostCore; +impl Core for HostCore { + fn execute(&self, command: &str, payload: Value) -> Reply { + super::super::scan::core_execute(command, payload) + .map_err(|detail| ProviderError::new("provider.coreFailed", detail)) + } + fn start(&self, payload: Value, registered: &dyn Fn(&str)) -> Reply { + lithe_core::start_lsp_owned(payload, registered) + .map_err(|error| ProviderError::new("provider.startFailed", format!("{error:?}"))) + } +} + +pub(super) async fn call( + transport: &Arc, + command: &'static str, + payload: Value, +) -> Reply { + let core = transport.core.clone(); + // A wedged write does not strand an async waiter. The native owner and + // package barrier stay alive while Core's transport maintenance kills it. + tokio::time::timeout( + Duration::from_secs(5), + tauri::async_runtime::spawn_blocking(move || core.execute(command, payload)), + ) + .await + .map_err(|_| ProviderError::new("provider.transportTimeout", command))? + .map_err(|error| ProviderError::new("provider.workerFailed", error.to_string()))? +} + +/// Failure leaves the transport in the registry, with its NativeLease. Retry +/// is allowed; package replacement cannot pass wait_native until this succeeds. +pub(super) async fn stop( + inner: Arc, + transport: Arc, +) -> Result<(), ProviderError> { + transport.revoke(ProviderError::new( + "provider.cancelled", + "Native owner closed", + )); + let _stopping = tokio::time::timeout(Duration::from_secs(12), transport.stopping.lock()) + .await + .map_err(|_| { + ProviderError::new( + "provider.stopFailed", + "Another cleanup attempt is still pending", + ) + })?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(8); + loop { + if dispose_once(&inner, &transport).await { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + return Err(ProviderError::new( + "provider.stopFailed", + "Package retained because process cleanup was not confirmed", + )); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + +pub(super) async fn dispose_once(inner: &Inner, transport: &Arc) -> bool { + let (finished, disposed, session) = { + let status = transport.status.lock().expect("provider session"); + ( + status.launch_finished, + status.disposed, + status.session_id.clone(), + ) + }; + if !disposed { + if let Some(session) = session { + // A registered session can be stopped while its first write is + // still returning. Never defer cancellation behind that write. + let _ = call(transport, "lsp.stopServer", json!({"sessionId":session})).await; + if call(transport, "lsp.destroyServer", json!({"sessionId":session})) + .await + .is_err() + { + return false; + } + transport.status.lock().expect("provider session").disposed = true; + } else if !finished { + return false; + } + } + // A late native launch worker still owns its frozen launch/probe inputs. + // Keep the package barrier until it has also reached its terminal path. + if !finished { + return false; + } + inner.release(transport); + true +} + +pub(super) fn pump(inner: Arc, transport: Arc) { + tauri::async_runtime::spawn(async move { + loop { + if transport.status.lock().expect("provider session").revoked { + break; + } + let Ok(session) = transport.session_id() else { + break; + }; + match call( + &transport, + "lsp.waitEvents", + json!({"sessionId":session,"timeoutMilliseconds":250}), + ) + .await + { + Ok(value) => { + for event in value["events"].as_array().into_iter().flatten() { + transport.observe(event); + } + } + Err(error) => { + transport.revoke(error); + break; + } + } + } + if let Err(error) = stop(inner, transport).await { + eprintln!( + "Provider cleanup retained its package barrier: {}", + error.detail + ); + } + }); +} + +pub(super) async fn ready(transport: &Arc) -> Result<(), ProviderError> { + tokio::time::timeout(Duration::from_secs(12), async { + loop { + let changed = transport.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + { + let status = transport.status.lock().expect("provider session"); + if status.revoked { + return Err(status.failure.clone().unwrap_or_else(|| { + ProviderError::new("provider.cancelled", "Owner closed") + })); + } + if status.ready && status.features_received { + return if status.negotiated { + Ok(()) + } else { + Err(ProviderError::new( + "provider.notNegotiated", + "Server did not negotiate litheBuild", + )) + }; + } + } + changed.await; + } + }) + .await + .map_err(|_| { + ProviderError::new( + "provider.initializeTimeout", + "Provider initialization timed out", + ) + })? +} + +pub(super) struct OpenGuard(pub Arc, pub Option>); +impl OpenGuard { + pub fn new(inner: Arc, transport: Arc) -> Self { + transport + .status + .lock() + .expect("provider session") + .open_waiters += 1; + Self(inner, Some(transport)) + } + pub fn commit(&mut self) { + if let Some(transport) = self.1.take() { + let mut status = transport.status.lock().expect("provider session"); + status.open_waiters -= 1; + status.bound = true; + } + } +} +impl Drop for OpenGuard { + fn drop(&mut self) { + if let Some(transport) = self.1.take() { + { + let mut status = transport.status.lock().expect("provider session"); + status.open_waiters -= 1; + if status.open_waiters > 0 || status.bound { + return; + } + } + let inner = self.0.clone(); + transport.revoke(ProviderError::new( + "provider.cancelled", + "Initialization cancelled", + )); + cleanup_later(inner, transport); + } + } +} + +pub(super) fn cleanup_later(inner: Arc, transport: Arc) { + tauri::async_runtime::spawn(async move { + if let Err(error) = stop(inner, transport).await { + eprintln!( + "Provider owner retained after failed cleanup: {}", + error.detail + ); + } + }); +} + +#[derive(Default)] +struct Dispatch { + cancelled: AtomicBool, + order: std::sync::Mutex<()>, +} + +struct RequestGuard(Arc, String, Arc); +impl Drop for RequestGuard { + fn drop(&mut self) { + let transport = self.0.clone(); + let operation = self.1.clone(); + let dispatch = self.2.clone(); + dispatch.cancelled.store(true, Ordering::Release); + transport.finish( + &operation, + Err(ProviderError::new( + "provider.cancelled", + "Request cancelled", + )), + ); + tauri::async_runtime::spawn(async move { + if let Ok(session) = transport.session_id() { + // Serialize cancellation after an already-started Core call. + // If the pool has not dispatched it, the atomic flag prevents + // a late buildRequest from starting after its waiter closed. + let cancelled = tauri::async_runtime::spawn_blocking(move || { + let _order = dispatch.order.lock().expect("provider dispatch"); + transport.core.execute( + "lsp.cancelOperation", + json!({"sessionId":session,"operationId":operation}), + ) + }); + if let Ok(Err(error)) = cancelled.await { + eprintln!("Could not cancel Provider request: {}", error.detail); + } + } + }); + } +} + +impl ProviderBinding { + /// `plan` comes unchanged from build.modelPlan/dependencyPlan. No domain + /// result parsing happens here. The async caller's cancellation drops a + /// guard that resolves the waiter and cancels Core's operation. + pub(crate) async fn request( + &self, + app: &tauri::AppHandle, + window: &tauri::WebviewWindow, + plan: Value, + timeout_ms: u64, + ) -> Reply { + super::host::validate_binding_async(app, window.label(), self).await?; + let timeout_ms = timeout_ms.clamp(1, 60 * 60 * 1000); + let operation = format!("native-provider-{}", super::host::next_id()); + let payload = self.request_payload(&operation, &plan, timeout_ms)?; + let reply = dispatch_request(&self.transport, operation, payload, timeout_ms).await?; + super::host::validate_binding_async(app, window.label(), self).await?; + Ok(reply) + } +} + +/// One request waiter owns its dispatch/cancellation ordering, independent of +/// other bindings sharing this transport. +pub(super) async fn dispatch_request( + transport: &Arc, + operation: String, + payload: Value, + timeout_ms: u64, +) -> Reply { + let receiver = transport.queue(&operation)?; + let dispatch = Arc::new(Dispatch::default()); + let guard = RequestGuard(transport.clone(), operation.clone(), dispatch.clone()); + let worker = transport.clone(); + let sent = tauri::async_runtime::spawn_blocking(move || { + let _order = dispatch.order.lock().expect("provider dispatch"); + if dispatch.cancelled.load(Ordering::Acquire) + || worker.status.lock().expect("provider session").revoked + { + return Err(ProviderError::new( + "provider.cancelled", + "Request closed before dispatch", + )); + } + worker.core.execute("lsp.buildRequest", payload) + }); + tokio::time::timeout(Duration::from_secs(5), sent) + .await + .map_err(|_| { + ProviderError::new("provider.transportTimeout", "Provider dispatch timed out") + })? + .map_err(|error| ProviderError::new("provider.workerFailed", error.to_string()))??; + let reply = tokio::time::timeout(Duration::from_millis(timeout_ms), receiver) + .await + .map_err(|_| ProviderError::new("provider.requestTimeout", "Provider request timed out"))? + .map_err(|_| ProviderError::new("provider.sessionEnded", "Provider waiter disconnected"))?; + drop(guard); + reply +} diff --git a/windows/tauri/src-tauri/src/plugin_host/provider/session.rs b/windows/tauri/src-tauri/src/plugin_host/provider/session.rs new file mode 100644 index 0000000000..11842bc1c7 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/provider/session.rs @@ -0,0 +1,268 @@ +//! Transport ownership and waiter bookkeeping, independent of Tauri and time. + +use serde::Serialize; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use tokio::sync::{oneshot, Notify}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProviderContext { + pub window: String, + pub workspace_root: String, + /// Original native workspace path used by existing settings/state adapters. + /// Canonical identity above must not change their persisted directory key. + pub launch_workspace_root: String, + pub generation: u64, + pub plugin_id: String, + /// Canonical package directory and the digest of every package file. + pub package_identity: String, + pub build_system: String, + /// Core's normalized workspace-relative root, forwarded byte-for-byte. + pub instance_root: String, + pub instance_directory: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProviderError { + pub code: &'static str, + pub detail: String, +} + +impl ProviderError { + pub(super) fn new(code: &'static str, detail: impl Into) -> Self { + Self { + code, + detail: detail.into(), + } + } +} + +pub(super) type Reply = Result; + +pub(super) fn same_root(left: &str, right: &str) -> bool { + #[cfg(windows)] + { + let normalize = |value: &str| { + let value = value + .replace('\\', "/") + .trim_end_matches('/') + .to_lowercase(); + if let Some(unc) = value.strip_prefix("//?/unc/") { + format!("//{unc}") + } else { + value.trim_start_matches("//?/").to_owned() + } + }; + normalize(left) == normalize(right) + } + #[cfg(not(windows))] + { + left == right + } +} + +/// This adapter calls the existing engine; tests script the boundary, never +/// supply a second JSON-RPC parser or build result interpreter. +pub(super) trait Core: Send + Sync { + fn execute(&self, command: &str, payload: Value) -> Reply; + fn start(&self, payload: Value, registered: &dyn Fn(&str)) -> Reply; +} + +#[derive(Default)] +pub(super) struct Status { + pub generation: u64, + pub open_waiters: usize, + pub bound: bool, + pub session_id: Option, + pub launch_finished: bool, + pub disposed: bool, + pub revoked: bool, + pub ready: bool, + pub negotiated: bool, + pub features_received: bool, + pub failure: Option, + pub(super) pending: HashMap>, +} + +pub(super) struct Transport { + pub id: u64, + pub context: ProviderContext, + /// Some only for an existing same-plugin language session. Its sole + /// event consumer remains the editor; the native Core bridge copies events. + pub language: Option, + pub launch_plan: Option, + pub status: Mutex, + pub changed: Notify, + pub stopping: tokio::sync::Mutex<()>, + pub core: Arc, + /// Retained across crashes, cancelled starts and failed disposal. Dropping + /// an arbitrary ProviderBinding never releases package ownership. + pub lease: Mutex>, +} + +impl Transport { + pub fn matches_owner(&self, context: &ProviderContext) -> bool { + self.context.window == context.window + && self.context.workspace_root == context.workspace_root + && self.status.lock().expect("provider session").generation == context.generation + && self.context.plugin_id == context.plugin_id + && self.context.package_identity == context.package_identity + } + + pub fn matches_shared(&self, context: &ProviderContext, language: &str) -> bool { + self.matches_owner(context) + && self.language.as_deref() == Some(language) + && !self.status.lock().expect("provider session").revoked + } + + pub fn observe(&self, event: &Value) { + let mut status = self.status.lock().expect("provider session"); + if event["sessionId"].as_str() != status.session_id.as_deref() { + return; + } + if status.revoked { + return; + } + match event["type"].as_str() { + Some("featuresChanged") => { + status.features_received = true; + status.negotiated = event["capabilities"] + .as_array() + .is_some_and(|items| items.iter().any(|feature| feature == "litheBuild")); + } + Some("stateChanged") => match event["state"].as_str() { + Some("ready") => status.ready = true, + Some("stopping" | "stopped" | "failed") => { + fail( + &mut status, + ProviderError::new("provider.sessionEnded", event.to_string()), + ); + } + _ => {} + }, + Some("requestCompleted") => { + if let Some(operation) = event["operationId"].as_str() { + if let Some(waiter) = status.pending.remove(operation) { + let reply = if !event["error"].is_null() { + Err(ProviderError::new( + "provider.requestFailed", + event["error"].to_string(), + )) + } else if let Some(value) = event["result"].get("value") { + Ok(value.clone()) + } else { + Err(ProviderError::new( + "provider.invalidResult", + "Core returned no provider value", + )) + }; + let _ = waiter.send(reply); + } + } + } + _ => {} + } + drop(status); + self.changed.notify_waiters(); + } + + pub fn revoke(&self, error: ProviderError) { + fail(&mut self.status.lock().expect("provider session"), error); + self.changed.notify_waiters(); + } + + pub fn queue(&self, operation: &str) -> Result, ProviderError> { + let mut status = self.status.lock().expect("provider session"); + if status.revoked || !status.ready || !status.negotiated { + return Err(status.failure.clone().unwrap_or_else(|| { + ProviderError::new( + "provider.notReady", + "Provider has not negotiated litheBuild", + ) + })); + } + if status.pending.contains_key(operation) { + return Err(ProviderError::new("provider.duplicateOperation", operation)); + } + if status.pending.len() >= 256 { + return Err(ProviderError::new( + "provider.busy", + "Too many pending native requests", + )); + } + let (sender, receiver) = oneshot::channel(); + status.pending.insert(operation.into(), sender); + Ok(receiver) + } + + pub fn finish(&self, operation: &str, result: Reply) { + if let Some(waiter) = self + .status + .lock() + .expect("provider session") + .pending + .remove(operation) + { + let _ = waiter.send(result); + } + } + + pub fn session_id(&self) -> Result { + self.status + .lock() + .expect("provider session") + .session_id + .clone() + .ok_or_else(|| { + ProviderError::new("provider.startPending", "Process start has not returned") + }) + } +} + +fn fail(status: &mut Status, error: ProviderError) { + status.revoked = true; + status.ready = false; + status.failure = Some(error.clone()); + for (_, waiter) in status.pending.drain() { + let _ = waiter.send(Err(error.clone())); + } +} + +/// A native binding cannot be forged from a webview-supplied session ID. +/// Its context keeps each build system and instance separate even when the +/// underlying language process is shared. +#[derive(Clone)] +pub(crate) struct ProviderBinding { + pub(super) transport: Arc, + pub(super) context: ProviderContext, + pub(super) approval_plan: Value, +} + +impl ProviderBinding { + pub(crate) fn context(&self) -> &ProviderContext { + &self.context + } + + /// Caller supplies only Core's provider plan. This verifies the envelope + /// identity without interpreting or changing its domain payload. + pub(super) fn request_payload(&self, operation: &str, plan: &Value, timeout_ms: u64) -> Reply { + if plan["root"].as_str() != Some(&self.context.instance_root) + || plan["buildSystemID"].as_str() != Some(&self.context.build_system) + || plan["provider"]["params"]["root"] != plan["root"] + || plan["provider"]["params"]["buildSystemID"] != plan["buildSystemID"] + { + return Err(ProviderError::new( + "provider.identityChanged", + "Core plan does not match its native instance", + )); + } + Ok(json!({ + "sessionId": self.transport.session_id()?, "operationId": operation, + "method": plan["provider"]["method"], "params": plan["provider"]["params"], + "timeoutMilliseconds": timeout_ms, + })) + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/provider/tests.rs b/windows/tauri/src-tauri/src/plugin_host/provider/tests.rs new file mode 100644 index 0000000000..12f33f4aa8 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/provider/tests.rs @@ -0,0 +1,483 @@ +//! Deterministic ownership regression tests: no servers, sleeps or app windows. + +use super::host::{matches_language_launch, preflight, Inner}; +use super::runtime::dispose_once; +use super::session::{ + Core, ProviderBinding, ProviderContext, ProviderError, Reply, Status, Transport, +}; +use serde_json::{json, Value}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +#[derive(Default)] +struct ScriptedCore { + reject_disposal: AtomicBool, + calls: Mutex>, +} +impl Core for ScriptedCore { + fn execute(&self, command: &str, _payload: Value) -> Reply { + self.calls.lock().unwrap().push(command.into()); + if command == "lsp.destroyServer" && self.reject_disposal.load(Ordering::Acquire) { + return Err(ProviderError::new( + "provider.stopFailed", + "scripted descendant still alive", + )); + } + Ok(json!({})) + } + fn start(&self, _payload: Value, registered: &dyn Fn(&str)) -> Reply { + registered("session-1"); + Err(ProviderError::new( + "provider.startFailed", + "scripted first write failed", + )) + } +} + +fn context() -> ProviderContext { + ProviderContext { + window: "workspace-a".into(), + workspace_root: "/fixture/workspace".into(), + launch_workspace_root: "/fixture/workspace".into(), + generation: 7, + plugin_id: "fixture.provider".into(), + package_identity: "package:whole-digest".into(), + build_system: "fixture-system".into(), + instance_root: "module-a".into(), + instance_directory: "/fixture/workspace/module-a".into(), + } +} + +fn entry(core: Arc) -> Arc { + Arc::new(Transport { + id: 1, + context: context(), + language: None, + launch_plan: None, + status: Mutex::new(Status { + generation: 7, + session_id: Some("session-1".into()), + launch_finished: true, + ready: true, + negotiated: true, + ..Default::default() + }), + changed: Default::default(), + stopping: Default::default(), + core, + lease: Mutex::new(None), + }) +} + +fn plan() -> Value { + json!({"root":context().instance_root,"buildSystemID":context().build_system, + "provider":{"method":"lithe/build/model","params":{ + "root":context().instance_root,"buildSystemID":context().build_system,"options":{"mode":"test"}, + }}}) +} + +#[test] +fn identity_rejects_cross_instance_and_build_system_before_request() { + let transport = entry(Arc::new(ScriptedCore::default())); + let binding = ProviderBinding { + approval_plan: json!({}), + transport, + context: context(), + }; + let valid = plan(); + let payload = binding.request_payload("op", &valid, 250).unwrap(); + assert_eq!(payload["params"], valid["provider"]["params"]); + for path in ["root", "buildSystemID"] { + let mut wrong = valid.clone(); + wrong[path] = json!("foreign"); + assert_eq!( + binding.request_payload("op", &wrong, 250).unwrap_err().code, + "provider.identityChanged" + ); + let mut wrong = valid.clone(); + wrong["provider"]["params"][path] = json!("foreign"); + assert!(binding.request_payload("op", &wrong, 250).is_err()); + } +} + +#[test] +fn native_instance_proof_keeps_core_relative_root_and_refuses_escape() { + let workspace = std::env::temp_dir().canonicalize().unwrap(); + let actual = super::super::build_resolver::WindowsBuildToolResolver::instance_root_directory( + ".", &workspace, + ) + .unwrap(); + assert_eq!(actual, workspace); + for root in ["../escape", "/absolute", "C:/absolute", r"..\escape"] { + assert!( + super::super::build_resolver::WindowsBuildToolResolver::instance_root_directory( + root, &workspace + ) + .is_err(), + "{root}" + ); + } +} + +#[cfg(windows)] +#[test] +fn canonical_windows_prefix_does_not_change_core_provider_params() { + assert!(super::session::same_root( + r"\\?\C:\fixture\workspace\module-a", + "C:/fixture/workspace/module-a" + )); + assert!(super::session::same_root( + r"\\?\UNC\server\share\project", + r"\\server\share\project" + )); +} + +#[test] +fn one_completion_resolves_only_its_native_session_and_operation() { + let transport = entry(Arc::new(ScriptedCore::default())); + let mut first = transport.queue("op-a").unwrap(); + let mut second = transport.queue("op-b").unwrap(); + assert!(transport.queue("op-a").is_err()); + let event = json!({"type":"requestCompleted","sessionId":"foreign","operationId":"op-a","result":{"value":{"modules":[]}}}); + transport.observe(&event); + assert!(matches!( + first.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + let mut event = event; + event["sessionId"] = json!("session-1"); + transport.observe(&event); + assert_eq!(first.try_recv().unwrap().unwrap(), json!({"modules":[]})); + transport.observe(&event); // Duplicate and late events cannot resolve another waiter. + assert!(matches!( + second.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + transport.revoke(ProviderError::new("provider.cancelled", "test cleanup")); + assert!(second.try_recv().unwrap().is_err()); +} + +#[test] +fn crash_resolves_every_waiter_and_cannot_be_revived_by_late_ready() { + let transport = entry(Arc::new(ScriptedCore::default())); + let mut first = transport.queue("op-a").unwrap(); + let mut second = transport.queue("op-b").unwrap(); + transport.observe(&json!({"type":"stateChanged","sessionId":"session-1","state":"failed"})); + assert_eq!( + first.try_recv().unwrap().unwrap_err().code, + "provider.sessionEnded" + ); + assert!(second.try_recv().unwrap().is_err()); + transport.observe(&json!({"type":"stateChanged","sessionId":"session-1","state":"ready"})); + assert!(transport.queue("late").is_err()); +} + +#[test] +fn unnegotiated_shared_session_never_receives_build_requests() { + let transport = entry(Arc::new(ScriptedCore::default())); + transport.observe( + &json!({"type":"featuresChanged","sessionId":"session-1","capabilities":["hover"]}), + ); + assert_eq!(transport.queue("op").unwrap_err().code, "provider.notReady"); + transport.observe( + &json!({"type":"featuresChanged","sessionId":"session-1","capabilities":["litheBuild"]}), + ); + let mut pending = transport.queue("op").unwrap(); + // A deterministic timeout/cancel consumes the waiter before a late reply. + transport.finish( + "op", + Err(ProviderError::new( + "provider.requestTimeout", + "test deadline", + )), + ); + transport.observe(&json!({"type":"requestCompleted","sessionId":"session-1","operationId":"op","result":{"value":{}}})); + assert_eq!( + pending.try_recv().unwrap().unwrap_err().code, + "provider.requestTimeout" + ); +} + +#[tokio::test] +async fn failed_disposal_preserves_owner_and_lease_until_positive_core_proof() { + let core = Arc::new(ScriptedCore::default()); + core.reject_disposal.store(true, Ordering::Release); + let transport = entry(core.clone()); + let lifecycle = super::super::lifecycle::LifecycleState::default(); + *transport.lease.lock().unwrap() = Some(lifecycle.acquire("fixture.provider").unwrap()); + let owners = Inner::default(); + owners.retain_for_test(transport.clone()); + transport.revoke(ProviderError::new("provider.cancelled", "test close")); + assert!(!dispose_once(&owners, &transport).await); + assert!(owners.contains_for_test(1)); + assert!(transport.lease.lock().unwrap().is_some()); + core.reject_disposal.store(false, Ordering::Release); + assert!(dispose_once(&owners, &transport).await); + assert!(!owners.contains_for_test(1)); + assert!(transport.lease.lock().unwrap().is_none()); + assert!(dispose_once(&owners, &transport).await); // Concurrent close is idempotent. + assert_eq!( + *core.calls.lock().unwrap(), + vec![ + "lsp.stopServer", + "lsp.destroyServer", + "lsp.stopServer", + "lsp.destroyServer" + ] + ); +} + +#[tokio::test] +async fn cancelling_start_keeps_barrier_until_late_start_is_owned_and_disposed() { + let core = Arc::new(ScriptedCore::default()); + let transport = entry(core.clone()); + { + let mut status = transport.status.lock().unwrap(); + status.launch_finished = false; + status.session_id = None; + } + let owners = Inner::default(); + owners.retain_for_test(transport.clone()); + transport.revoke(ProviderError::new( + "provider.cancelled", + "initialization cancelled", + )); + assert!(!dispose_once(&owners, &transport).await); + assert!(owners.contains_for_test(1)); + assert!(core.calls.lock().unwrap().is_empty()); + assert!(core + .start(json!({}), &|id| transport + .status + .lock() + .unwrap() + .session_id = Some(id.into())) + .is_err()); + assert!(!dispose_once(&owners, &transport).await); + assert!( + transport.status.lock().unwrap().disposed, + "registered child is stopped before the first write returns" + ); + assert!( + owners.contains_for_test(1), + "late launch worker still retains its package inputs" + ); + transport.status.lock().unwrap().launch_finished = true; + assert!(dispose_once(&owners, &transport).await); + assert!(!owners.contains_for_test(1)); +} + +#[test] +fn shared_start_must_match_the_native_verified_language_launch() { + let approved = json!({"pluginId":"fixture","languageId":"lang","serverPath":"/fixture/server", + "serverArgs":["--stdio"],"initializationOptions":null,"workspaceConfiguration":null,"environment":{"TOOL":"/fixture/tool"}}); + let payload = json!({"pluginId":"fixture","providerId":"fixture","executablePath":"/fixture/server", + "arguments":["--stdio"],"initializationOptions":null,"workspaceConfiguration":null,"environment":{"TOOL":"/fixture/tool"}}); + assert!(matches_language_launch(&approved, &payload)); + for field in [ + "pluginId", + "providerId", + "executablePath", + "arguments", + "initializationOptions", + "workspaceConfiguration", + "environment", + "hostServices", + "runtimeExecutablePath", + "jdtlsLaunchResources", + "cacheDirectory", + ] { + let mut wrong = payload.clone(); + wrong[field] = json!("foreign"); + assert!(!matches_language_launch(&approved, &wrong), "{field}"); + } + let mut extra = payload.clone(); + extra["environment"]["UNAPPROVED"] = json!("value"); + assert!(!matches_language_launch(&approved, &extra)); +} + +#[test] +fn shared_owner_requires_window_workspace_generation_plugin_digest_and_language() { + let mut transport = entry(Arc::new(ScriptedCore::default())); + Arc::get_mut(&mut transport).unwrap().language = Some("lang".into()); + let scope = context(); + assert!(transport.matches_shared(&scope, "lang")); + assert!(!transport.matches_shared(&scope, "foreign")); + let mut sibling = scope.clone(); + sibling.build_system = "sibling-system".into(); + sibling.instance_root = "module-b".into(); + sibling.instance_directory = "/fixture/workspace/module-b".into(); + assert!(transport.matches_shared(&sibling, "lang")); + for changed in 0..5 { + let mut wrong = scope.clone(); + match changed { + 0 => wrong.window = "foreign".into(), + 1 => wrong.workspace_root = "/foreign".into(), + 2 => wrong.generation += 1, + 3 => wrong.plugin_id = "foreign".into(), + _ => wrong.package_identity = "package:replacement-digest".into(), + } + assert!( + !transport.matches_shared(&wrong, "lang"), + "scope field {changed}" + ); + } + transport.revoke(ProviderError::new("provider.cancelled", "scope closed")); + assert!(!transport.matches_shared(&scope, "lang")); +} + +#[tokio::test] +async fn bridge_refuses_foreign_or_unapproved_requests_and_acknowledges_only_verified_cleanup() { + let core = Arc::new(ScriptedCore::default()); + let mut transport = entry(core.clone()); + Arc::get_mut(&mut transport).unwrap().language = Some("lang".into()); + let owners = Inner::default(); + owners.retain_for_test(transport.clone()); + let payload = json!({"sessionId":"session-1"}); + assert!(preflight(&owners, "foreign", "lsp.pollEvents", &payload) + .unwrap() + .is_err()); + assert!( + preflight(&owners, "workspace-a", "lsp.buildRequest", &payload) + .unwrap() + .is_err() + ); + assert!(preflight(&owners, "workspace-a", "lsp.pollEvents", &payload).is_none()); + core.reject_disposal.store(true, Ordering::Release); + assert!(!dispose_once(&owners, &transport).await); + assert!( + preflight(&owners, "workspace-a", "lsp.destroyServer", &payload).is_none(), + "failed cleanup cannot synthesize a success" + ); + core.reject_disposal.store(false, Ordering::Release); + assert!(dispose_once(&owners, &transport).await); + assert!( + preflight(&owners, "workspace-a", "lsp.stopServer", &payload) + .unwrap() + .is_ok() + ); + assert!( + preflight(&owners, "workspace-a", "lsp.destroyServer", &payload) + .unwrap() + .is_ok() + ); + assert!(preflight(&owners, "foreign", "lsp.destroyServer", &payload) + .unwrap() + .is_err()); +} + +#[test] +fn real_core_contract_plans_distinguish_dedicated_and_same_plugin_language_sources() { + for (manifest, expected) in [ + (include_str!(concat!(env!("CARGO_MANIFEST_DIR"),"/../../../shared/contracts/plugins/v1/fixtures/build-provider.plugin.json")),None), + (include_str!(concat!(env!("CARGO_MANIFEST_DIR"),"/../../../shared/contracts/plugins/v1/fixtures/build-provider-language-server.plugin.json")),Some("elixir")), + ] { + let manifest:Value=serde_json::from_str(manifest).unwrap(); + let result=super::super::scan::core_execute("plugins.declarative.languageServerLaunchPlan",json!({ + "buildSystemID":manifest["buildSystems"][0]["id"],"manifest":manifest, + "request":{"pluginDirectory":"/fixture/plugin","stateDirectory":"/fixture/state", + "workspaceStateDirectory":"/fixture/state/workspace","architecture":"x86_64","settings":{},"projectSettings":{}}, + })).unwrap(); + assert_ne!(result["status"],"rejected","{result}"); + assert_eq!(result["plan"]["buildProvider"]["languageServer"].as_str(),expected); + assert!(result["plan"]["buildProvider"]["executesProjectCode"].as_bool().unwrap()); + let model=super::super::scan::core_execute("build.modelPlan",json!({ + "buildSystemID":manifest["buildSystems"][0]["id"],"manifest":manifest, + "request":{"root":"module-a","options":{}}, + })).unwrap(); + assert_eq!(model["plan"]["root"],"module-a","{model}"); + let transport=entry(Arc::new(ScriptedCore::default())); + let mut context=context(); + context.build_system=manifest["buildSystems"][0]["id"].as_str().unwrap().into(); + let binding=ProviderBinding{transport,context,approval_plan:json!({})}; + let forwarded=binding.request_payload("core-plan",&model["plan"],500).unwrap(); + assert_eq!(forwarded["params"],model["plan"]["provider"]["params"]); + } +} + +#[test] +fn cancelling_one_initialization_waiter_preserves_the_other_waiter() { + let transport = entry(Arc::new(ScriptedCore::default())); + let owners = Arc::new(Inner::default()); + let first = super::runtime::OpenGuard::new(owners.clone(), transport.clone()); + let mut second = super::runtime::OpenGuard::new(owners, transport.clone()); + assert_eq!(transport.status.lock().unwrap().open_waiters, 2); + drop(first); + assert!(!transport.status.lock().unwrap().revoked); + second.commit(); + assert!(transport.status.lock().unwrap().bound); + assert_eq!(transport.status.lock().unwrap().open_waiters, 0); +} + +#[tokio::test] +async fn cancelling_one_dispatched_request_does_not_cancel_a_shared_waiter() { + #[derive(Default)] + struct Commands { + values: Mutex>, + changed: tokio::sync::Notify, + } + impl Commands { + async fn until(&self, count: usize) { + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let changed = self.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + if self.values.lock().unwrap().len() >= count { + return; + } + changed.await; + } + }) + .await + .expect("native dispatch gate"); + } + } + impl Core for Commands { + fn execute(&self, command: &str, payload: Value) -> Reply { + self.values.lock().unwrap().push((command.into(), payload)); + self.changed.notify_waiters(); + Ok(json!({})) + } + fn start(&self, _: Value, _: &dyn Fn(&str)) -> Reply { + panic!("sharing must never launch another process") + } + } + let core = Arc::new(Commands::default()); + let mut transport = entry(Arc::new(ScriptedCore::default())); + Arc::get_mut(&mut transport).unwrap().core = core.clone(); + let mut first = Box::pin(super::runtime::dispatch_request( + &transport, + "first".into(), + json!({"operationId":"first"}), + 5000, + )); + let mut second = Box::pin(super::runtime::dispatch_request( + &transport, + "second".into(), + json!({"operationId":"second"}), + 5000, + )); + // Both requests reach Core before cancellation; the observable dispatch + // gate establishes ordering without scheduler-turn or wall-clock guesses. + tokio::select! { + result=&mut first=>panic!("first completed before reply: {result:?}"), + result=&mut second=>panic!("second completed before reply: {result:?}"), + _=core.until(2)=>{}, + } + drop(first); + core.until(3).await; + { + let values = core.values.lock().unwrap(); + assert_eq!(values[2].0, "lsp.cancelOperation"); + assert_eq!(values[2].1["operationId"], "first"); + } + transport.observe(&json!({"type":"requestCompleted","sessionId":"session-1","operationId":"first","result":{"value":{"late":true}}})); + transport.observe(&json!({"type":"requestCompleted","sessionId":"session-1","operationId":"second","result":{"value":{"kept":true}}})); + let reply = tokio::time::timeout(std::time::Duration::from_secs(2), second) + .await + .unwrap() + .unwrap(); + assert_eq!(reply, json!({"kept":true})); + assert!(!transport.status.lock().unwrap().revoked); + core.until(4).await; // Drain the final harmless cancellation before teardown. +} diff --git a/windows/tauri/src-tauri/src/plugin_host/run_plans.rs b/windows/tauri/src-tauri/src/plugin_host/run_plans.rs index e7f0b2eda9..3ac3f172e1 100644 --- a/windows/tauri/src-tauri/src/plugin_host/run_plans.rs +++ b/windows/tauri/src-tauri/src/plugin_host/run_plans.rs @@ -1,92 +1,524 @@ -//! Run and test plans for declarative plugins. -//! -//! Core expands the declared templates into an executable and arguments; the -//! host resolves the executable like any other launch (`run_resolve_launch`) -//! and starts the process through the shared run pipeline. Process ownership -//! is tracked per workspace and plugin on the frontend, exactly like Worker -//! extension run actions. - +//! Passive execution discovery and exact reviewed launches. Core owns test +//! semantics; the existing Run manager owns output and process trees. use super::scan::core_execute; -use serde::Serialize; -use serde_json::Value; -use std::path::Path; -use tauri::AppHandle; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::io::Read; +use std::path::{Component, Path}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Manager, WebviewWindow}; + +#[cfg(test)] +mod tests; + +/// Captured frontend runtime incarnation, bound to the injected native window. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExecutionContext { + pub client_id: String, + pub workspace_id: String, + pub workspace_path: String, + pub generation: String, +} + +/// Admission epochs only; all live/pending processes remain in Run's table. +#[derive(Default)] +pub struct ExecutionState(Mutex>); + +#[derive(Default)] +struct ExecutionEpoch { + client: String, + retired_clients: HashSet, + closed_generations: HashSet, + revision: u64, + context: Option, +} +impl ExecutionEpoch { + fn retire(&mut self) { + if !self.client.is_empty() { + self.retired_clients.insert(self.client.clone()); + } + if let Some(context) = self.context.take() { + self.closed_generations.insert(context.generation); + } + } + fn update( + &mut self, + client: String, + revision: u64, + context: Option, + ) -> Result<(), String> { + if client.is_empty() + || self.retired_clients.contains(&client) + || context + .as_ref() + .is_some_and(|context| self.closed_generations.contains(&context.generation)) + || context + .as_ref() + .is_some_and(|context| context.client_id != client) + || (self.client == client && self.revision >= revision) + { + return Err("plugin.execution.staleContext".into()); + } + // A webview reload starts a new sequence, while queued IPC from its + // previous document can never regain admission afterwards. + if self.client != client && !self.client.is_empty() { + if self.retired_clients.len() >= 1024 { + return Err("plugin.execution.workspaceLimit".into()); + } + self.retired_clients.insert(self.client.clone()); + } + self.client = client; + self.revision = revision; + self.context = context; + Ok(()) + } +} + +#[tauri::command] +pub fn declarative_plugins_close_execution_workspace( + app: AppHandle, + window: WebviewWindow, + workspace_id: String, +) -> Result<(), String> { + if !(window.label() == "main" || window.label().starts_with("workspace-")) { + return Err("plugin.execution.staleContext".into()); + } + let state = app.state::(); + let mut epochs = state + .0 + .lock() + .map_err(|_| "plugin.execution.staleContext")?; + if let Some(epoch) = epochs.get_mut(window.label()) { + if epoch + .context + .as_ref() + .is_some_and(|context| context.workspace_id == workspace_id) + { + if let Some(context) = epoch.context.take() { + epoch.closed_generations.insert(context.generation); + } + } + } + Ok(()) +} + +pub fn window_destroyed(app: &AppHandle, label: &str) { + if let Some(epoch) = app + .state::() + .0 + .lock() + .expect("execution admission") + .get_mut(label) + { + epoch.retire(); + } +} -/// An expanded run or test plan ready for the shared run pipeline. +#[tauri::command] +pub fn declarative_plugins_execution_context( + app: AppHandle, + window: WebviewWindow, + client_id: String, + revision: u64, + context: Option, +) -> Result<(), String> { + if !(window.label() == "main" || window.label().starts_with("workspace-")) { + return Err("plugin.execution.staleContext".into()); + } + let state = app.state::(); + let mut epochs = state + .0 + .lock() + .map_err(|_| "plugin.execution.staleContext")?; + epochs + .entry(window.label().into()) + .or_default() + .update(client_id, revision, context) +} + +/// Existing Core scopes. Suites are explicitly unavailable rather than being +/// turned into a workspace run or a guessed test-case identifier. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +pub enum TestScope { + Workspace {}, + File { + #[serde(rename = "relativeFilePath")] + relative_file_path: String, + }, + TestCase { + identifier: String, + #[serde( + default, + rename = "relativeFilePath", + skip_serializing_if = "Option::is_none" + )] + relative_file_path: Option, + }, + Suite { + identifier: String, + }, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +pub enum ExecutionSelection { + Run { + #[serde(rename = "relativeFilePath")] + relative_file_path: String, + #[serde(default, rename = "userArguments")] + user_arguments: Vec, + }, + Test { + scope: TestScope, + }, +} + +/// Core argv with a receipt covering package bytes, exact selection and scope. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct DeclarativeRunPlan { pub plugin_id: String, pub language_id: String, - /// Executable command as declared (e.g. `php`); the frontend resolves it - /// through `run_resolve_launch` like any extension run action. pub executable: Value, pub arguments: Vec, pub working_directory: String, + pub label: Option, + pub framework_id: Option, + /// Empty only for the legacy plan-only API without workspace context. + pub identity: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeclarativeTests { + pub items: Value, + pub identity: String, } -/// Expands the `run` template for one file of a language a plugin owns. #[tauri::command] -pub fn declarative_plugins_run_plan( +pub async fn declarative_plugins_run_plan( app: AppHandle, + window: WebviewWindow, plugin_id: String, language_id: String, relative_file_path: String, user_arguments: Vec, + context: Option, ) -> Result { - let package = super::launch::package_by_id(&app, &plugin_id) - .ok_or_else(|| format!("{plugin_id} is not an accepted declarative plugin"))?; - expand_plan( - &package.manifest, - &plugin_id, - &language_id, - "plugins.declarative.runPlan", - serde_json::json!({ - "relativeFilePath": relative_file_path, - "arguments": user_arguments, - }), - ) + let label = window.label().to_string(); + passive(move || { + prepare( + &app, + &label, + &plugin_id, + &language_id, + context.as_ref(), + &ExecutionSelection::Run { + relative_file_path, + user_arguments, + }, + ) + }) + .await } -/// Expands the workspace test template for a language a plugin owns. -/// The host checks declared project markers in the workspace before Core -/// decides whether the test template applies. #[tauri::command] -pub fn declarative_plugins_test_plan( +pub async fn declarative_plugins_test_plan( app: AppHandle, + window: WebviewWindow, plugin_id: String, language_id: String, workspace_path: String, + scope: Option, + context: Option, ) -> Result { - let package = super::launch::package_by_id(&app, &plugin_id) - .ok_or_else(|| format!("{plugin_id} is not an accepted declarative plugin"))?; - let relative_project_file_paths = - existing_project_markers(&package.manifest, &language_id, Path::new(&workspace_path)); - expand_plan( - &package.manifest, + let label = window.label().to_string(); + passive(move || { + if context + .as_ref() + .is_some_and(|value| value.workspace_path != workspace_path) + { + return Err("plugin.execution.staleContext".into()); + } + let selection = ExecutionSelection::Test { + scope: scope.unwrap_or(TestScope::Workspace {}), + }; + // API 1/2 plan callers retain workspace defaults, without a launch receipt. + if context.is_none() { + let _lease = app + .state::() + .acquire(&plugin_id)?; + let package = super::launch::verified_package_by_id(&app, &plugin_id)?; + let files = + existing_root_markers(&package.manifest, &language_id, Path::new(&workspace_path)); + return expand_plan( + &package.manifest, + &plugin_id, + &language_id, + &selection, + &files, + ); + } + prepare( + &app, + &label, + &plugin_id, + &language_id, + context.as_ref(), + &selection, + ) + }) + .await +} + +#[tauri::command] +pub async fn declarative_plugins_discover_tests( + app: AppHandle, + window: WebviewWindow, + plugin_id: String, + language_id: String, + context: ExecutionContext, +) -> Result { + let label = window.label().to_string(); + passive(move || { + let _lease = app + .state::() + .acquire(&plugin_id)?; + check_context(&app, &label, &context)?; + let package = super::launch::verified_package_by_id(&app, &plugin_id)?; + let files = inventory(Path::new(&context.workspace_path))?; + let response = core_execute( + "plugins.declarative.discoverTests", + json!({ + "manifest": package.manifest, "languageID": language_id, + "request": { "relativeProjectFilePaths": files }, + }), + )?; + core_ok(&response)?; + let items = response["items"].clone(); + if !items.is_array() { + return Err("plugin.execution.invalidPlan".into()); + } + let identity = receipt(&app, &label, &context, &package, &files, &items)?; + Ok(DeclarativeTests { items, identity }) + }) + .await +} + +async fn passive( + work: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tauri::async_runtime::spawn_blocking(work) + .await + .map_err(|error| error.to_string())? +} + +async fn prepare_current( + app: &AppHandle, + label: &str, + plugin: &str, + language: &str, + context: &ExecutionContext, + selection: &ExecutionSelection, +) -> Result { + let (app, label, plugin, language, context, selection) = ( + app.clone(), + label.to_string(), + plugin.to_string(), + language.to_string(), + context.clone(), + selection.clone(), + ); + passive(move || prepare(&app, &label, &plugin, &language, Some(&context), &selection)).await +} + +/// Re-expand the reviewed request against live inputs. Never execute argv or +/// an executable supplied by the frontend. The existing Run pipeline owns it. +#[tauri::command] +pub async fn declarative_plugins_start_execution( + app: AppHandle, + window: WebviewWindow, + plugin_id: String, + language_id: String, + context: ExecutionContext, + selection: ExecutionSelection, + expected_identity: String, + session_id: String, + execution_id: String, +) -> Result<(), String> { + if expected_identity.is_empty() || session_id.is_empty() || execution_id.is_empty() { + return Err("plugin.execution.approvalRequired".into()); + } + let _lease = app + .state::() + .acquire(&plugin_id)?; + let plan = prepare_current( + &app, + window.label(), &plugin_id, &language_id, - "plugins.declarative.testPlan", - serde_json::json!({ - "scope": { "kind": "workspace" }, - "relativeProjectFilePaths": relative_project_file_paths, - }), + &context, + &selection, ) + .await?; + if plan.identity != expected_identity { + return Err("plugin.execution.staleContext".into()); + } + // Discovery may probe tools, so it happens only after explicit approval. + let args = serde_json::from_value(json!({ "root": context.workspace_path, + "executable": plan.executable, "workingDirectory": plan.working_directory, + })) + .map_err(|_| "plugin.execution.invalidPlan")?; + let resolved = + tauri::async_runtime::spawn_blocking(move || crate::run::run_resolve_launch(args)) + .await + .map_err(|error| error.to_string())??; + let checked = prepare_current( + &app, + window.label(), + &plugin_id, + &language_id, + &context, + &selection, + ) + .await?; + if checked.identity != expected_identity { + return Err("plugin.execution.staleContext".into()); + } + let admission_app = app.clone(); + let admission_label = window.label().to_string(); + let admission_context = context.clone(); + crate::run::run_start_process_with_admission( + app.clone(), + crate::run::StartProcessArgs { + window_label: window.label().into(), + session_id: session_id.clone(), + execution_id: Some(execution_id.clone()), + title: plan.label, + background: false, + executable: resolved.executable, + arguments: plan.arguments, + working_directory: resolved.working_directory, + environment: resolved.environment, + }, + move |start| { + if admission_app.get_webview_window(&admission_label).is_none() { + return Err("plugin.execution.staleContext".into()); + } + let state = admission_app.state::(); + let epochs = state + .0 + .lock() + .map_err(|_| "plugin.execution.staleContext")?; + if epochs + .get(&admission_label) + .and_then(|epoch| epoch.context.as_ref()) + != Some(&admission_context) + || admission_app + .state::() + .blocked(&plugin_id) + { + return Err("plugin.execution.staleContext".into()); + } + // Context revocation and spawn/publication share this lock. A revoke + // before admission cannot launch, a revoke after it sees an owned tree. + start() + }, + ) + .await?; + if let Err(error) = check_context(&app, window.label(), &context) { + crate::run::run_stop_process(window.label().into(), session_id, Some(execution_id)).await?; + return Err(error); + } + Ok(()) +} + +fn check_context(app: &AppHandle, label: &str, context: &ExecutionContext) -> Result<(), String> { + if !(label == "main" || label.starts_with("workspace-")) + || app.get_webview_window(label).is_none() + || context.workspace_id.is_empty() + || context.generation.is_empty() + || !Path::new(&context.workspace_path).is_absolute() + || !Path::new(&context.workspace_path).is_dir() + { + return Err("plugin.execution.staleContext".into()); + } + if app + .state::() + .0 + .lock() + .map_err(|_| "plugin.execution.staleContext")? + .get(label) + .and_then(|epoch| epoch.context.as_ref()) + != Some(context) + { + return Err("plugin.execution.staleContext".into()); + } + Ok(()) +} + +fn prepare( + app: &AppHandle, + label: &str, + plugin: &str, + language: &str, + context: Option<&ExecutionContext>, + selection: &ExecutionSelection, +) -> Result { + let _lease = app + .state::() + .acquire(plugin)?; + let package = super::launch::verified_package_by_id(app, plugin)?; + let files = if let Some(context) = context { + check_context(app, label, context)?; + match selection { + ExecutionSelection::Run { + relative_file_path, .. + } => { + // File runs need no workspace scan. Core validates the path + // before the host reads that exact source below. + let _ = expand_plan(&package.manifest, plugin, language, selection, &[])?; + vec![relative_file_path.clone()] + } + ExecutionSelection::Test { .. } => inventory(Path::new(&context.workspace_path))?, + } + } else { + Vec::new() + }; + validate_selection(selection, &files, context.is_some())?; + let mut plan = expand_plan(&package.manifest, plugin, language, selection, &files)?; + if let Some(context) = context { + plan.identity = receipt( + app, + label, + context, + &package, + &files, + &json!({ "language": language, "selection": selection, "plan": plan, + "source": source_identity(Path::new(&context.workspace_path), selection)?, }), + )?; + } + Ok(plan) } -fn existing_project_markers(manifest: &Value, language_id: &str, root: &Path) -> Vec { +fn existing_root_markers(manifest: &Value, language: &str, root: &Path) -> Vec { let mut markers: Vec = manifest["executionProviders"] .as_array() .into_iter() .flatten() - .filter(|provider| provider["languageID"] == language_id) + .filter(|provider| provider["languageID"] == language) .flat_map(|provider| { provider["test"]["projectMarkers"] .as_array() .into_iter() .flatten() }) - .filter_map(|marker| marker.as_str()) + .filter_map(Value::as_str) .filter(|marker| root.join(marker).is_file()) .map(str::to_string) .collect(); @@ -95,72 +527,278 @@ fn existing_project_markers(manifest: &Value, language_id: &str, root: &Path) -> markers } +fn source_identity(root: &Path, selection: &ExecutionSelection) -> Result { + let relative = match selection { + ExecutionSelection::Run { + relative_file_path, .. + } => Some(relative_file_path), + ExecutionSelection::Test { + scope: TestScope::File { relative_file_path }, + } => Some(relative_file_path), + ExecutionSelection::Test { + scope: TestScope::TestCase { + relative_file_path, .. + }, + } => relative_file_path.as_ref(), + _ => None, + }; + let Some(relative) = relative else { + return Ok(Value::Null); + }; + let root = root.canonicalize().map_err(|_| "plugin.path.invalid")?; + let mut component_path = root.clone(); + for component in Path::new(relative).components() { + let Component::Normal(part) = component else { + return Err("plugin.path.invalid".into()); + }; + component_path.push(part); + if is_link(&std::fs::symlink_metadata(&component_path).map_err(|_| "plugin.path.invalid")?) + { + return Err("plugin.path.invalid".into()); + } + } + let path = root.join(relative); + let metadata = std::fs::symlink_metadata(&path).map_err(|_| "plugin.path.invalid")?; + if !metadata.is_file() + || is_link(&metadata) + || !path + .canonicalize() + .map_err(|_| "plugin.path.invalid")? + .starts_with(&root) + { + return Err("plugin.path.invalid".into()); + } + const MAX_SOURCE_BYTES: u64 = 32 * 1024 * 1024; + let mut bytes = Vec::new(); + std::fs::File::open(path) + .map_err(|_| "plugin.execution.workspaceReadFailed")? + .take(MAX_SOURCE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| "plugin.execution.workspaceReadFailed")?; + if bytes.len() as u64 > MAX_SOURCE_BYTES { + return Err("plugin.execution.workspaceLimit".into()); + } + Ok(json!({ "path": relative, "sha256": format!("{:x}", Sha256::digest(bytes)) })) +} + +fn validate_selection( + selection: &ExecutionSelection, + files: &[String], + require_file: bool, +) -> Result<(), String> { + let path = match selection { + ExecutionSelection::Run { + relative_file_path, .. + } => Some(relative_file_path), + ExecutionSelection::Test { + scope: TestScope::File { relative_file_path }, + } => Some(relative_file_path), + ExecutionSelection::Test { + scope: TestScope::TestCase { + relative_file_path, .. + }, + } => relative_file_path.as_ref(), + ExecutionSelection::Test { + scope: TestScope::Suite { .. }, + } => return Err("plugin.execution.unavailable".into()), + _ => None, + }; + if let Some(path) = path { + if require_file && !files.contains(path) { + return Err("plugin.path.invalid".into()); + } + } + Ok(()) +} + +fn receipt( + app: &AppHandle, + label: &str, + context: &ExecutionContext, + package: &super::ScannedPackage, + files: &[String], + output: &Value, +) -> Result { + check_context(app, label, context)?; + if app + .state::() + .blocked(&package.entry.plugin_id) + { + return Err("plugin.execution.staleContext".into()); + } + let current = super::launch::verified_package_by_id(app, &package.entry.plugin_id)?; + if current.root != package.root + || current.manifest != package.manifest + || current.entry.version != package.entry.version + { + return Err("plugin.execution.packageChanged".into()); + } + let digest = + super::signature::content_digest(¤t.root).map_err(|error| error.message())?; + let bytes = serde_json::to_vec(&json!({ + "window": label, "context": context, + "root": Path::new(&context.workspace_path).canonicalize().map_err(|_| "plugin.path.invalid")?, + "package": digest, "manifest": current.manifest, "files": files, + "workspaceFiles": inventory_identity(Path::new(&context.workspace_path), files)?, "output": output, + })).map_err(|error| error.to_string())?; + Ok(format!("{:x}", Sha256::digest(bytes))) +} + +/// Filesystem revision facts only. Actual test identities/argv stay in Core. +fn inventory_identity(root: &Path, files: &[String]) -> Result { + let deadline = Instant::now() + Duration::from_secs(10); + let mut hash = Sha256::new(); + for relative in files { + if Instant::now() >= deadline { + return Err("plugin.execution.workspaceLimit".into()); + } + let meta = std::fs::symlink_metadata(root.join(relative)) + .map_err(|_| "plugin.execution.staleContext")?; + if !meta.is_file() || is_link(&meta) { + return Err("plugin.execution.staleContext".into()); + } + let modified = meta + .modified() + .map_err(|_| "plugin.execution.workspaceReadFailed")? + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| "plugin.execution.workspaceReadFailed")?; + hash.update( + serde_json::to_vec(&json!([ + relative, + meta.len(), + modified.as_secs(), + modified.subsec_nanos() + ])) + .map_err(|error| error.to_string())?, + ); + } + Ok(format!("{:x}", hash.finalize())) +} + +/// Bounded filesystem facts, not a test matcher. Core alone decides which +/// files/markers are tests. Reparse points are excluded; selecting one fails. +fn inventory(root: &Path) -> Result, String> { + let root = root.canonicalize().map_err(|_| "plugin.path.invalid")?; + let mut pending = vec![(root.clone(), 0usize)]; + let mut files = Vec::new(); + let deadline = Instant::now() + Duration::from_secs(10); + let mut count = 0usize; + while let Some((directory, depth)) = pending.pop() { + if depth > 64 { + return Err("plugin.execution.workspaceLimit".into()); + } + for entry in + std::fs::read_dir(directory).map_err(|_| "plugin.execution.workspaceReadFailed")? + { + if Instant::now() >= deadline { + return Err("plugin.execution.workspaceLimit".into()); + } + count += 1; + if count > 100_000 { + return Err("plugin.execution.workspaceLimit".into()); + } + let entry = entry.map_err(|_| "plugin.execution.workspaceReadFailed")?; + let meta = std::fs::symlink_metadata(entry.path()) + .map_err(|_| "plugin.execution.workspaceReadFailed")?; + if is_link(&meta) { + continue; + } + if meta.is_dir() { + if entry.file_name() != ".git" { + pending.push((entry.path(), depth + 1)); + } + } else if meta.is_file() { + let path = entry.path(); + let relative = path + .strip_prefix(&root) + .map_err(|_| "plugin.path.invalid")?; + if relative + .components() + .any(|part| !matches!(part, Component::Normal(_))) + { + return Err("plugin.path.invalid".into()); + } + files.push( + relative + .to_str() + .ok_or("plugin.path.invalid")? + .replace('\\', "/"), + ); + } + } + } + files.sort(); + files.dedup(); + Ok(files) +} + +fn is_link(meta: &std::fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + meta.file_attributes() & 0x400 != 0 + } + #[cfg(not(windows))] + { + meta.file_type().is_symlink() + } +} + +fn core_ok(response: &Value) -> Result<(), String> { + if response["status"] != "ok" { + return Err(response["error"]["code"] + .as_str() + .unwrap_or("plugin.execution.invalidPlan") + .into()); + } + Ok(()) +} + fn expand_plan( manifest: &Value, - plugin_id: &str, - language_id: &str, - command: &str, - request: Value, + plugin: &str, + language: &str, + selection: &ExecutionSelection, + files: &[String], ) -> Result { - let plan = core_execute( + validate_selection(selection, files, false)?; + let (command, request) = match selection { + ExecutionSelection::Run { + relative_file_path, + user_arguments, + } => ( + "plugins.declarative.runPlan", + json!({ "relativeFilePath": relative_file_path, "arguments": user_arguments }), + ), + ExecutionSelection::Test { scope } => ( + "plugins.declarative.testPlan", + json!({ "scope": scope, "relativeProjectFilePaths": files }), + ), + }; + let response = core_execute( command, - serde_json::json!({ - "manifest": manifest, - "languageID": language_id, - "request": request, - }), + json!({ "manifest": manifest, "languageID": language, "request": request }), )?; - if plan["status"] == serde_json::json!("rejected") { - return Err(format!( - "{command} rejected: {}", - plan["error"]["message"].as_str().unwrap_or("unknown") - )); + core_ok(&response)?; + let body = &response["plan"]; + let executable = body["executable"].clone(); + let arguments = serde_json::from_value(body["arguments"].clone()) + .map_err(|_| "plugin.execution.invalidPlan")?; + let working_directory = body["workingDirectory"] + .as_str() + .ok_or("plugin.execution.invalidPlan")? + .to_string(); + if !executable.is_object() { + return Err("plugin.execution.invalidPlan".into()); } - let body = &plan["plan"]; Ok(DeclarativeRunPlan { - plugin_id: plugin_id.to_string(), - language_id: language_id.to_string(), - executable: body["executable"].clone(), - arguments: body["arguments"] - .as_array() - .map(|items| { - items - .iter() - .filter_map(|item| item.as_str().map(str::to_string)) - .collect() - }) - .unwrap_or_default(), - working_directory: body["workingDirectory"].as_str().unwrap_or(".").to_string(), + plugin_id: plugin.into(), + language_id: language.into(), + executable, + arguments, + working_directory, + label: body["label"].as_str().map(str::to_string), + framework_id: body["frameworkID"].as_str().map(str::to_string), + identity: String::new(), }) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_markers_come_from_existing_workspace_files_for_the_language() { - let root = std::env::temp_dir().join(format!( - "lithe-test-markers-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock") - .as_nanos() - )); - std::fs::create_dir_all(&root).expect("workspace"); - std::fs::write(root.join("composer.json"), "{}").expect("marker"); - let manifest = serde_json::json!({ - "executionProviders": [ - {"languageID": "php", "test": {"projectMarkers": ["composer.json", "phpunit.xml"]}}, - {"languageID": "go", "test": {"projectMarkers": ["go.mod"]}} - ] - }); - assert_eq!( - existing_project_markers(&manifest, "php", &root), - vec!["composer.json"] - ); - assert!(existing_project_markers(&manifest, "go", &root).is_empty()); - std::fs::remove_dir_all(root).expect("cleanup"); - } -} diff --git a/windows/tauri/src-tauri/src/plugin_host/run_plans/tests.rs b/windows/tauri/src-tauri/src/plugin_host/run_plans/tests.rs new file mode 100644 index 0000000000..65be8e235c --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/run_plans/tests.rs @@ -0,0 +1,300 @@ +//! Real Core fixture parity and bounded filesystem admission, without tools. +use super::*; +use std::sync::atomic::{AtomicU64, Ordering}; + +fn fixture(name: &str) -> Value { + serde_json::from_str( + &std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../shared/contracts/plugins/v1/fixtures") + .join(name), + ) + .unwrap(), + ) + .unwrap() +} + +struct Workspace(std::path::PathBuf); +impl Workspace { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "lithe-execution-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + fn write(&self, path: &str) { + let path = self.0.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "fixture").unwrap(); + } +} +impl Drop for Workspace { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn real_core_workspace_file_and_case_plans_keep_the_exact_selection() { + let manifest = fixture("language-packaged-server.plugin.json"); + let files = vec!["composer.json".into(), "tests/ItemTest.php".into()]; + for (scope, arguments) in [ + (TestScope::Workspace {}, json!(["vendor/bin/phpunit"])), + ( + TestScope::File { + relative_file_path: "tests/ItemTest.php".into(), + }, + json!(["vendor/bin/phpunit", "tests/ItemTest.php"]), + ), + ( + TestScope::TestCase { + identifier: "Suite.test[one]".into(), + relative_file_path: Some("tests/ItemTest.php".into()), + }, + json!([ + "vendor/bin/phpunit", + "--filter", + "\\bSuite\\.test\\[one\\]\\b", + "tests/ItemTest.php" + ]), + ), + ] { + let plan = expand_plan( + &manifest, + "fixture", + "php", + &ExecutionSelection::Test { scope }, + &files, + ) + .unwrap(); + assert_eq!(json!(plan.arguments), arguments); + assert_eq!(plan.framework_id.as_deref(), Some("phpunit")); + } + let mut api_three = manifest.clone(); + api_three["pluginAPIVersion"] = json!(3); + api_three["requires"] = + json!({ "languages":1, "languageServers":1, "serverProcess":1, "executionProviders":1 }); + let api_three_plan = expand_plan( + &api_three, + "fixture", + "php", + &ExecutionSelection::Test { + scope: TestScope::File { + relative_file_path: "tests/ItemTest.php".into(), + }, + }, + &files, + ) + .unwrap(); + assert_eq!( + api_three_plan.arguments, + ["vendor/bin/phpunit", "tests/ItemTest.php"] + ); + let run = expand_plan( + &manifest, + "fixture", + "php", + &ExecutionSelection::Run { + relative_file_path: "-file.php".into(), + user_arguments: vec!["two words".into()], + }, + &files, + ) + .unwrap(); + assert_eq!(run.arguments, ["./-file.php", "two words"]); +} + +#[test] +fn discovery_uses_core_items_from_nested_workspace_files_without_parsing_source() { + let workspace = Workspace::new(); + workspace.write("nested/composer.json"); + workspace.write("nested/tests/ItemTest.php"); + workspace.write("nested/src/Item.php"); + workspace.write(".git/privateTest.php"); + let files = inventory(&workspace.0).unwrap(); + assert_eq!( + files, + [ + "nested/composer.json", + "nested/src/Item.php", + "nested/tests/ItemTest.php" + ] + ); + let response = core_execute( + "plugins.declarative.discoverTests", + json!({ + "manifest": fixture("language-packaged-server.plugin.json"), "languageID": "php", + "request": { "relativeProjectFilePaths": files }, + }), + ) + .unwrap(); + core_ok(&response).unwrap(); + assert_eq!(response["items"].as_array().unwrap().len(), 2); + assert_eq!( + response["items"][1]["relativeFilePath"], + "nested/tests/ItemTest.php" + ); +} + +#[test] +fn unsupported_suites_invalid_cases_and_missing_files_never_widen_the_run() { + let manifest = fixture("language-packaged-server.plugin.json"); + let files = vec!["composer.json".into()]; + let suite = ExecutionSelection::Test { + scope: TestScope::Suite { + identifier: "Suite".into(), + }, + }; + assert_eq!( + expand_plan(&manifest, "fixture", "php", &suite, &files).unwrap_err(), + "plugin.execution.unavailable" + ); + let case = ExecutionSelection::Test { + scope: TestScope::TestCase { + identifier: "bad\ncase".into(), + relative_file_path: None, + }, + }; + assert_eq!( + expand_plan(&manifest, "fixture", "php", &case, &files).unwrap_err(), + "plugin.test.invalidIdentifier" + ); + let file = ExecutionSelection::Test { + scope: TestScope::File { + relative_file_path: "missing.php".into(), + }, + }; + assert_eq!( + validate_selection(&file, &files, true).unwrap_err(), + "plugin.path.invalid" + ); + assert_eq!( + expand_plan( + &manifest, + "fixture", + "php", + &ExecutionSelection::Test { + scope: TestScope::Workspace {} + }, + &[] + ) + .unwrap_err(), + "plugin.test.unsupportedProject" + ); +} + +#[test] +fn unsafe_scope_fields_and_current_capability_gate_stay_closed() { + assert!(serde_json::from_value::( + json!({ "kind":"workspace", "identifier":"silently ignored" }) + ) + .is_err()); + assert!(serde_json::from_value::( + json!({ "kind":"test", "scope":{"kind":"workspace"}, "suite":"ignored" }) + ) + .is_err()); + assert!(super::super::host_capabilities()["executionProviders"].is_null()); + let mut manifest = fixture("language-packaged-server.plugin.json"); + manifest["pluginAPIVersion"] = json!(3); + manifest["requires"] = + json!({ "languages":1, "languageServers":1, "serverProcess":1, "executionProviders":1 }); + let rejected = super::super::scan::validate_manifest_with( + &manifest, + Some(super::super::host_capabilities()), + ) + .unwrap_err(); + assert_eq!(rejected.code, "plugin.api.unsupported"); +} + +#[test] +fn admission_rejects_old_revisions_old_documents_and_other_workspace_generations() { + let context = ExecutionContext { + client_id: "document-one".into(), + workspace_id: "north".into(), + workspace_path: "C:/fixture/north".into(), + generation: "runtime-one".into(), + }; + let mut epoch = ExecutionEpoch::default(); + epoch + .update("document-one".into(), 1, Some(context.clone())) + .unwrap(); + assert_eq!(epoch.context.as_ref(), Some(&context)); + epoch.update("document-one".into(), 3, None).unwrap(); + assert!(epoch + .update("document-one".into(), 2, Some(context.clone())) + .is_err()); + assert!(epoch.context.is_none()); + let reloaded = ExecutionContext { + client_id: "document-two".into(), + generation: "runtime-two".into(), + ..context.clone() + }; + epoch + .update("document-two".into(), 1, Some(reloaded.clone())) + .unwrap(); + assert!(epoch + .update("document-one".into(), 100, Some(context)) + .is_err()); + assert_eq!(epoch.context.as_ref(), Some(&reloaded)); + let mut windows = HashMap::new(); + windows.insert("main", epoch); + windows + .entry("workspace-other") + .or_insert_with(ExecutionEpoch::default) + .update("other".into(), 1, None) + .unwrap(); + assert_eq!(windows["main"].context.as_ref(), Some(&reloaded)); + assert!(windows["workspace-other"].context.is_none()); + windows.get_mut("main").unwrap().retire(); + assert!(windows + .get_mut("main") + .unwrap() + .update("document-two".into(), 99, Some(reloaded)) + .is_err()); +} + +#[test] +fn selected_file_bytes_are_part_of_the_reviewed_plan_identity() { + let workspace = Workspace::new(); + workspace.write("tests/ItemTest.php"); + let selection = ExecutionSelection::Test { + scope: TestScope::File { + relative_file_path: "tests/ItemTest.php".into(), + }, + }; + let before = source_identity(&workspace.0, &selection).unwrap(); + let files = inventory(&workspace.0).unwrap(); + let workspace_before = inventory_identity(&workspace.0, &files).unwrap(); + std::fs::write( + workspace.0.join("tests/ItemTest.php"), + "changed after review", + ) + .unwrap(); + assert_ne!(before, source_identity(&workspace.0, &selection).unwrap()); + assert_ne!( + workspace_before, + inventory_identity(&workspace.0, &files).unwrap() + ); + let outside = ExecutionSelection::Run { + relative_file_path: "../outside.php".into(), + user_arguments: vec![], + }; + assert!(source_identity(&workspace.0, &outside).is_err()); +} + +#[test] +fn legacy_test_markers_keep_their_root_only_api_one_and_two_behavior() { + let workspace = Workspace::new(); + workspace.write("composer.json"); + workspace.write("nested/phpunit.xml"); + let manifest = fixture("language-packaged-server.plugin.json"); + assert_eq!( + existing_root_markers(&manifest, "php", &workspace.0), + ["composer.json"] + ); + assert!(existing_root_markers(&manifest, "go", &workspace.0).is_empty()); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/scan.rs b/windows/tauri/src-tauri/src/plugin_host/scan.rs index 59bb55e0b6..15a2dc80fe 100644 --- a/windows/tauri/src-tauri/src/plugin_host/scan.rs +++ b/windows/tauri/src-tauri/src/plugin_host/scan.rs @@ -24,10 +24,16 @@ pub fn build_catalog_from( ) -> Result, String> { let mut discovered = store::discover_bundled_in(bundled_root)?; discovered.extend(store::discover_installed_in(install_root)?); - Ok(discovered.into_iter().map(scan_package).collect()) + Ok(discovered + .into_iter() + .map(|package| scan_package(package, Some(bundled_root))) + .collect()) } -fn scan_package(package: DiscoveredPackage) -> ScannedPackage { +fn scan_package( + package: DiscoveredPackage, + bundled_root: Option<&std::path::Path>, +) -> ScannedPackage { let fallback_id = package .root .file_name() @@ -54,13 +60,48 @@ fn scan_package(package: DiscoveredPackage) -> ScannedPackage { } match validate_manifest(&manifest) { Ok(plugin) => { + let plugin_id = plugin["id"].as_str().unwrap_or(&fallback_id).to_string(); + let version = plugin["version"].as_str().unwrap_or("0").to_string(); + // The trust gate runs after manifest validation so Core's stable + // manifest codes stay primary, and before the package is + // registered: signature policy, origin, and the approved content + // digest must all hold for the bytes on disk right now. + let Some(origin) = package.origin else { + return quarantined( + package, + plugin_id, + &version, + catalog_rejection( + super::signature::TrustError::ProvenanceMissing.code(), + super::signature::TrustError::ProvenanceMissing.message(), + None, + ), + ); + }; + if let Err(error) = super::signature::verify_for_origin( + &package.root, + &plugin_id, + &version, + &manifest, + origin, + package.content_digest.as_deref(), + bundled_root, + &super::signature::trusted_keys(), + ) { + return quarantined( + package, + plugin_id, + &version, + catalog_rejection(error.code(), error.message(), None), + ); + } let entry = CatalogEntry { - plugin_id: plugin["id"].as_str().unwrap_or(&fallback_id).to_string(), + plugin_id: plugin_id.clone(), display_name: plugin["displayName"] .as_str() .unwrap_or("plugin") .to_string(), - version: plugin["version"].as_str().unwrap_or("0").to_string(), + version: version.clone(), api_version: plugin["pluginAPIVersion"].as_u64().unwrap_or(0) as u32, source: package.source, enabled: package.enabled, @@ -85,6 +126,10 @@ fn scan_package(package: DiscoveredPackage) -> ScannedPackage { entry, root: package.root, manifest, + trust: Some(super::TrustEvidence { + origin, + expected_content_digest: package.content_digest, + }), } } Err(rejection) => quarantined( @@ -119,6 +164,7 @@ fn quarantined( }, root: package.root, manifest: Value::Null, + trust: None, } } @@ -242,6 +288,9 @@ pub(crate) fn validate_manifest(manifest: &Value) -> Result ScannedPackage { - scan_package(package) +pub(crate) fn scan_package_for_tests( + package: super::store::DiscoveredPackage, + bundled_root: Option<&std::path::Path>, +) -> ScannedPackage { + scan_package(package, bundled_root) } diff --git a/windows/tauri/src-tauri/src/plugin_host/settings.rs b/windows/tauri/src-tauri/src/plugin_host/settings.rs index 94df0f2002..dcd7fe9daf 100644 --- a/windows/tauri/src-tauri/src/plugin_host/settings.rs +++ b/windows/tauri/src-tauri/src/plugin_host/settings.rs @@ -11,6 +11,28 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager}; +pub(crate) mod reload; +mod snapshot; +pub(crate) use snapshot::read as read_snapshot; +pub use snapshot::SettingSnapshot; +pub(crate) use snapshot::{read_object_snapshot, write_object_snapshot}; + +#[tauri::command] +pub async fn declarative_plugins_setting_snapshot( + app: AppHandle, + window: tauri::WebviewWindow, + plugin_id: String, + workspace_path: String, + workspace_id: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let snapshot = snapshot::read(&app, &plugin_id, &workspace_path)?; + reload::bind(&app, &window, &workspace_path, workspace_id, snapshot) + }) + .await + .map_err(|_| "plugin.settings.unavailable".to_string())? +} + /// Global values of one plugin, persisted as /// `app_local_data_dir()/plugin-settings/.json`. fn global_store_path(app: &AppHandle, plugin_id: &str) -> Result { @@ -32,32 +54,41 @@ pub fn project_store_path(workspace_path: &str) -> PathBuf { /// Reads a JSON object store; missing files read as empty. Corrupted files /// surface as errors so a broken write is never silently reset. fn read_object(path: &Path) -> Result, String> { - let text = match std::fs::read_to_string(path) { - Ok(text) => text, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Map::new()), - Err(error) => return Err(error.to_string()), - }; - let value: Value = serde_json::from_str(&text) - .map_err(|error| format!("{} is not valid JSON: {error}", path.display()))?; - match value { - Value::Object(map) => Ok(map), - _ => Err(format!("{} is not a JSON object", path.display())), - } + snapshot::read_values(path) } /// Writes a JSON object store atomically via temp file + rename. +#[cfg(test)] fn write_object(path: &Path, map: &Map) -> Result<(), String> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; } - let temp = path.with_extension("json.tmp"); - std::fs::write( - &temp, - serde_json::to_string_pretty(&Value::Object(map.clone())) - .map_err(|error| error.to_string())?, - ) - .map_err(|error| error.to_string())?; - std::fs::rename(&temp, path).map_err(|error| error.to_string()) + use std::io::Write; + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + let temp = path.with_extension(format!( + "json.{}-{}.tmp", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let text = serde_json::to_string_pretty(&Value::Object(map.clone())) + .map_err(|error| error.to_string())?; + let result = (|| { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(|error| error.to_string())?; + file.write_all(text.as_bytes()) + .map_err(|error| error.to_string())?; + file.sync_all().map_err(|error| error.to_string())?; + drop(file); + crate::logging::replace_file_atomically(&temp, path) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temp); + } + result } /// Both stored layers for one plugin. @@ -76,7 +107,17 @@ pub fn load_layers( let global: BTreeMap = read_object(&global_store_path(app, plugin_id)?)? .into_iter() .collect(); - let project_file = read_object(&project_store_path(workspace_path))?; + let project_file = if workspace_path.is_empty() { + Map::new() + } else { + read_object(&project_store_path(workspace_path))? + }; + if project_file + .get(plugin_id) + .is_some_and(|value| !value.is_object()) + { + return Err("plugin.settings.corruptStore".into()); + } let project: BTreeMap = project_file .get(plugin_id) .and_then(Value::as_object) @@ -90,9 +131,8 @@ pub fn load_layers( Ok(SettingLayers { global, project }) } -/// Stores the user's global value of one setting. A value equal to the -/// declared default is forgotten so default changes flow through (macOS -/// rule); `None` erases the stored value. +/// Stores an explicit global override; only `None` erases the stored value. +#[cfg(test)] pub fn store_global( app: &AppHandle, plugin_id: &str, @@ -116,6 +156,7 @@ pub fn store_global( /// with `None`. When the last override of a plugin disappears from the /// project store, the empty plugin entry is removed; an empty store file is /// deleted entirely. +#[cfg(test)] pub fn store_project( workspace_path: &str, plugin_id: &str, @@ -153,62 +194,179 @@ pub fn store_project( /// Tauri command: effective values of one plugin's settings. #[tauri::command] -pub fn declarative_plugins_resolve_settings( +pub async fn declarative_plugins_resolve_settings( app: AppHandle, manifest: Value, workspace_path: String, ) -> Result { - let Some(plugin_id) = manifest["id"].as_str().map(str::to_string) else { - return Err("manifest has no id".to_string()); - }; - let layers = load_layers(&app, &plugin_id, &workspace_path)?; - super::scan::core_execute( - "plugins.declarative.resolveSettings", - serde_json::json!({ - "manifest": manifest, - "settings": layers.global, - "projectSettings": layers.project, - }), - ) + tauri::async_runtime::spawn_blocking(move || { + let Some(plugin_id) = manifest["id"].as_str().map(str::to_string) else { + return Err("manifest has no id".to_string()); + }; + let _lease = app + .state::() + .acquire(&plugin_id)?; + let manifest = super::launch::verified_package_by_id(&app, &plugin_id)?.manifest; + let layers = load_layers(&app, &plugin_id, &workspace_path)?; + super::scan::core_execute( + "plugins.declarative.resolveSettings", + serde_json::json!({ + "manifest": manifest, + "settings": layers.global, + "projectSettings": layers.project, + }), + ) + }) + .await + .map_err(|_| "plugin.settings.unavailable".to_string())? } /// Tauri command: stores a setting value in the requested layer. #[tauri::command] -pub fn declarative_plugins_store_setting( +pub async fn declarative_plugins_store_setting( app: AppHandle, + window: tauri::WebviewWindow, plugin_id: String, setting_id: String, scope: String, workspace_path: String, value: Option, -) -> Result<(), String> { - match scope.as_str() { - "global" => store_global(&app, &plugin_id, &setting_id, value), - "project" => store_project(&workspace_path, &plugin_id, &setting_id, value), - other => Err(format!("unknown setting scope {other}")), + expected_identity: Option, + workspace_id: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let snapshot = snapshot::store( + &app, + &plugin_id, + &setting_id, + &scope, + &workspace_path, + value, + expected_identity + .as_deref() + .ok_or("plugin.settings.snapshotRequired")?, + )?; + reload::bind(&app, &window, &workspace_path, workspace_id, snapshot) + }) + .await + .map_err(|_| "plugin.settings.unavailable".to_string())? +} + +/// Reloads an already saved global setting. This never mutates installation +/// state or package bytes; a failed drain leaves the existing package revoked +/// and the same snapshot receipt can be retried through the mutation owner. +#[tauri::command] +pub async fn declarative_plugins_reload_settings( + app: AppHandle, + window: tauri::WebviewWindow, + plugin_id: String, + workspace_path: String, + scope: String, + expected_identity: String, + expected_package_identity: String, + project: Option, +) -> Result { + if !(window.label() == "main" || window.label().starts_with("workspace-")) { + return Err("plugin.settings.staleContext".into()); } + if scope == "project" { + let receipt = reload::Receipt { + plugin_id, + workspace_path, + expected_identity, + expected_package_identity, + project: project.ok_or("plugin.settings.staleContext")?, + }; + reload::check_caller(&window, &receipt)?; + return super::build::reload_settings_models(app, window.label().into(), receipt) + .await + .and_then(|view| { + serde_json::to_value(reload::ProjectReload { view }) + .map_err(|_| "plugin.settings.unavailable".into()) + }); + } + if scope != "global" { + return Err("plugin.settings.invalidScope".into()); + } + if !app + .state::() + .blocked(&plugin_id) + { + let preflight_app = app.clone(); + let preflight_plugin = plugin_id.clone(); + let preflight_root = workspace_path.clone(); + let current = tauri::async_runtime::spawn_blocking(move || { + snapshot::read(&preflight_app, &preflight_plugin, &preflight_root) + }) + .await + .map_err(|_| "plugin.settings.unavailable".to_string())??; + if current.package_identity != expected_package_identity { + return Err("plugin.settings.packageChanged".into()); + } + if current.identity != expected_identity { + return Err("plugin.settings.conflict".into()); + } + } + let mutation = super::lifecycle::begin(&app, &plugin_id) + .await + .map_err(|error| format!("plugin.settings.reloadStopFailed: {error}"))?; + let (mutation, snapshot) = tauri::async_runtime::spawn_blocking(move || { + let snapshot = snapshot::reload( + &app, + &mutation, + &workspace_path, + &expected_identity, + &expected_package_identity, + ); + (mutation, snapshot) + }) + .await + .map_err(|_| "plugin.settings.unavailable".to_string())?; + if snapshot + .as_ref() + .is_err_and(|error| error == "plugin.settings.conflict") + { + // Every old owner has stopped and the newly scanned package validated. + // Let the user read the concurrent values instead of trapping a stale + // receipt behind revocation. Normal launch still rechecks live trust. + mutation.complete(true); + return snapshot.and_then(|snapshot| { + serde_json::to_value(snapshot).map_err(|_| "plugin.settings.unavailable".into()) + }); + } + let snapshot = snapshot?; + mutation.complete(true); + serde_json::to_value(snapshot).map_err(|_| "plugin.settings.unavailable".into()) } /// Tauri command: a running language server's `workspaceConfiguration` /// expanded with the current values, for `immediate` effect changes. #[tauri::command] -pub fn declarative_plugins_workspace_configuration( +pub async fn declarative_plugins_workspace_configuration( app: AppHandle, manifest: Value, language_id: String, workspace_path: String, ) -> Result { - let Some(plugin_id) = manifest["id"].as_str().map(str::to_string) else { - return Err("manifest has no id".to_string()); - }; - let layers = load_layers(&app, &plugin_id, &workspace_path)?; - super::scan::core_execute( - "plugins.declarative.workspaceConfiguration", - serde_json::json!({ - "manifest": manifest, - "languageID": language_id, - "settings": layers.global, - "projectSettings": layers.project, - }), - ) + tauri::async_runtime::spawn_blocking(move || { + let Some(plugin_id) = manifest["id"].as_str().map(str::to_string) else { + return Err("manifest has no id".to_string()); + }; + let _lease = app + .state::() + .acquire(&plugin_id)?; + let manifest = super::launch::verified_package_by_id(&app, &plugin_id)?.manifest; + let layers = load_layers(&app, &plugin_id, &workspace_path)?; + super::scan::core_execute( + "plugins.declarative.workspaceConfiguration", + serde_json::json!({ + "manifest": manifest, + "languageID": language_id, + "settings": layers.global, + "projectSettings": layers.project, + }), + ) + }) + .await + .map_err(|_| "plugin.settings.unavailable".to_string())? } diff --git a/windows/tauri/src-tauri/src/plugin_host/settings/reload.rs b/windows/tauri/src-tauri/src/plugin_host/settings/reload.rs new file mode 100644 index 0000000000..455cd6511b --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/settings/reload.rs @@ -0,0 +1,178 @@ +//! Project reload uses the existing build owners. A native project claim, +//! saved settings and accepted package bind every read and model publication. +use super::*; +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicU64, Ordering}; + +struct WindowIncarnation(u64); +impl tauri::Resource for WindowIncarnation {} +static NEXT_WINDOW: AtomicU64 = AtomicU64::new(1); + +fn table_incarnation(table: &mut tauri::ResourceTable) -> u64 { + if let Some(id) = table + .names() + .find_map(|(id, _)| table.get::(id).ok().map(|owner| owner.0)) + { + return id; + } + let id = NEXT_WINDOW.fetch_add(1, Ordering::Relaxed); + table.add(WindowIncarnation(id)); + id +} + +fn window_incarnation(window: &tauri::WebviewWindow) -> u64 { + table_incarnation(&mut window.as_ref().window().resources_table()) +} + +fn check_incarnation(current: u64, expected: u64) -> Result<(), String> { + if current != expected { + return Err("plugin.settings.staleContext".into()); + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectContext { + pub workspace_id: String, + pub registration: u64, + pub window_incarnation: u64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Receipt { + pub plugin_id: String, + pub workspace_path: String, + pub expected_identity: String, + pub expected_package_identity: String, + pub project: ProjectContext, +} + +fn registration(app: &AppHandle, label: &str, root: &str, id: &str) -> Result { + if !(label == "main" || label.starts_with("workspace-")) + || app.get_webview_window(label).is_none() + { + return Err("plugin.settings.staleContext".into()); + } + let identity = crate::project_window_registry::ProjectWindowRegistry::identity(Path::new(root)) + .map_err(|_| "plugin.settings.staleContext")?; + app.state::() + .0 + .blocking_lock() + .registration(&identity, label, id) + .ok_or_else(|| "plugin.settings.staleContext".into()) +} + +pub(super) fn bind( + app: &AppHandle, + window: &tauri::WebviewWindow, + root: &str, + workspace_id: Option, + mut snapshot: SettingSnapshot, +) -> Result { + if !root.is_empty() { + if let Some(workspace_id) = workspace_id { + snapshot.reload_context = Some(ProjectContext { + registration: registration(app, window.label(), root, &workspace_id)?, + workspace_id, + window_incarnation: window_incarnation(window), + }); + } + } + Ok(snapshot) +} + +pub(crate) fn check_owner(app: &AppHandle, label: &str, receipt: &Receipt) -> Result<(), String> { + let window = app + .get_webview_window(label) + .ok_or("plugin.settings.staleContext")?; + check_caller(&window, receipt)?; + let current = registration( + app, + label, + &receipt.workspace_path, + &receipt.project.workspace_id, + )?; + if current != receipt.project.registration { + return Err("plugin.settings.staleContext".into()); + } + Ok(()) +} + +pub(super) fn check_caller(window: &tauri::WebviewWindow, receipt: &Receipt) -> Result<(), String> { + check_incarnation( + window_incarnation(window), + receipt.project.window_incarnation, + ) +} + +pub(crate) fn check( + app: &AppHandle, + label: &str, + receipt: &Receipt, +) -> Result { + check_owner(app, label, receipt)?; + let snapshot = snapshot::read(app, &receipt.plugin_id, &receipt.workspace_path)?; + validate(&snapshot, receipt)?; + check_owner(app, label, receipt)?; + Ok(snapshot) +} + +fn validate(snapshot: &SettingSnapshot, receipt: &Receipt) -> Result<(), String> { + if snapshot.package_identity != receipt.expected_package_identity { + return Err("plugin.settings.packageChanged".into()); + } + if snapshot.identity != receipt.expected_identity { + return Err("plugin.settings.conflict".into()); + } + Ok(()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectReload { + pub view: super::super::build_registry::BuildView, +} + +#[tauri::command] +pub async fn declarative_plugins_prepare_settings_reload_model( + app: AppHandle, + window: tauri::WebviewWindow, + receipt: Receipt, + build_system_id: String, + root: String, + runtime_executables: BTreeMap, +) -> Result { + check_caller(&window, &receipt)?; + super::super::build::prepare_settings_reload_model( + app, + window.label().into(), + receipt, + build_system_id, + root, + runtime_executables, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reload_receipt_is_bound_to_the_actual_window_resource_table() { + let mut original = tauri::ResourceTable::default(); + let mut replacement = tauri::ResourceTable::default(); + let old = table_incarnation(&mut original); + assert_eq!(table_incarnation(&mut original), old); + let next = table_incarnation(&mut replacement); + assert_ne!(old, next); + assert!(check_incarnation(old, old).is_ok()); + assert_eq!( + check_incarnation(next, old).unwrap_err(), + "plugin.settings.staleContext" + ); + assert!(check_incarnation(next, next).is_ok()); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/settings/snapshot.rs b/windows/tauri/src-tauri/src/plugin_host/settings/snapshot.rs new file mode 100644 index 0000000000..1f922a8a7b --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/settings/snapshot.rs @@ -0,0 +1,631 @@ +//! Guarded host-owned plugin preferences. These are the exact layers Core +//! consumes, never a copy of a tool's native configuration or a secret store. +use super::*; +use serde::Serialize; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::sync::Mutex; + +static WRITES: Mutex<()> = Mutex::new(()); + +#[derive(Clone)] +struct Store { + values: Map, + identity: Option, +} + +fn plain_source(path: &Path, writing: bool) -> Result<(), String> { + match std::fs::symlink_metadata(path) { + Ok(metadata) => { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return Err("plugin.settings.readOnlySource".into()); + } + } + if metadata.file_type().is_symlink() || (writing && metadata.permissions().readonly()) { + return Err("plugin.settings.readOnlySource".into()); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err("plugin.settings.readFailed".into()), + } + Ok(()) +} + +fn read_store(path: &Path) -> Result { + read_store_limited(path, None) +} +fn read_store_limited(path: &Path, limit: Option) -> Result { + if let Some(parent) = path.parent() { + plain_source(parent, false)?; + match std::fs::read_dir(parent) { + Ok(entries) => { + let prefix = format!( + "{}.", + path.file_name() + .ok_or("plugin.settings.readFailed")? + .to_string_lossy() + ); + for entry in entries { + let name = entry.map_err(|_| "plugin.settings.readFailed")?.file_name(); + let name = name.to_string_lossy(); + let recovery = name + .strip_prefix(&prefix) + .and_then(|name| name.strip_suffix(".backup")); + if recovery.and_then(|name| name.split_once('-')).is_some_and( + |(process, sequence)| { + !process.is_empty() + && !sequence.is_empty() + && process.bytes().all(|byte| byte.is_ascii_digit()) + && sequence.bytes().all(|byte| byte.is_ascii_digit()) + }, + ) { + return Err("plugin.settings.recoveryRequired".into()); + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err("plugin.settings.readFailed".into()), + } + } + plain_source(path, false)?; + let bytes = (|| { + use std::io::Read; + let mut bytes = Vec::new(); + let mut source = std::fs::File::open(path)?; + if let Some(limit) = limit { + source.take(limit + 1).read_to_end(&mut bytes)?; + } else { + source.read_to_end(&mut bytes)?; + } + if limit.is_some_and(|limit| bytes.len() as u64 > limit) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "JSON store limit", + )); + } + Ok(bytes) + })(); + match bytes { + Ok(bytes) => { + let values = serde_json::from_slice::(&bytes) + .map_err(|_| "plugin.settings.corruptStore")? + .as_object() + .cloned() + .ok_or("plugin.settings.corruptStore")?; + Ok(Store { + values, + identity: Some(format!("{:x}", Sha256::digest(bytes))), + }) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Store { + values: Map::new(), + identity: None, + }), + Err(_) => Err("plugin.settings.readFailed".into()), + } +} + +pub(super) fn read_values(path: &Path) -> Result, String> { + read_store(path).map(|store| store.values) +} + +/// Host-owned JSON consumers share the same conflict, ACL and recovery boundary. +pub(crate) fn read_object_snapshot( + path: &Path, + limit: u64, +) -> Result<(Map, Option), String> { + read_store_limited(path, Some(limit)).map(|store| (store.values, store.identity)) +} + +pub(crate) fn write_object_snapshot( + path: &Path, + values: &Map, + revision: &Option, + limit: u64, + preflight: impl Fn() -> Result<(), String>, + current: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + let _write = WRITES.lock().map_err(|_| "plugin.settings.unavailable")?; + let expected = read_store_limited(path, Some(limit))?; + if &expected.identity != revision { + return Err("plugin.settings.conflict".into()); + } + write_guarded_checked(path, values, &expected, Some(limit), preflight, current) +} + +// Compare the exact bytes from discovery again after preparing the replacement. +// Internal writers are serialized; external edits are never merged from an old +// UI snapshot. Atomic replacement reuses the host's Windows adapter. +fn write_guarded(path: &Path, values: &Map, expected: &Store) -> Result<(), String> { + write_guarded_checked(path, values, expected, None, || Ok(()), || Ok(())) +} +fn write_guarded_checked( + path: &Path, + values: &Map, + expected: &Store, + limit: Option, + preflight: impl Fn() -> Result<(), String>, + current: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + use std::io::Write; + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + preflight()?; + if let Some(parent) = path.parent() { + plain_source(parent, true)?; + } + plain_source(path, true)?; + if read_store_limited(path, limit)?.identity != expected.identity { + return Err("plugin.settings.conflict".into()); + } + if let Some(parent) = path.parent() { + preflight()?; + std::fs::create_dir_all(parent).map_err(|_| "plugin.settings.writeFailed")?; + } + let temp = path.with_extension(format!( + "json.{}-{}.tmp", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let mut owned_temporary = false; + let result = (|| { + preflight()?; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(|_| "plugin.settings.writeFailed")?; + owned_temporary = true; + serde_json::to_writer_pretty(&mut file, values) + .map_err(|_| "plugin.settings.writeFailed")?; + file.flush().map_err(|_| "plugin.settings.writeFailed")?; + if let Ok(metadata) = std::fs::metadata(path) { + file.set_permissions(metadata.permissions()) + .map_err(|_| "plugin.settings.writeFailed")?; + } + file.sync_all().map_err(|_| "plugin.settings.writeFailed")?; + drop(file); + current()?; + if read_store_limited(path, limit)?.identity != expected.identity { + return Err("plugin.settings.conflict".to_string()); + } + plain_source(path, true)?; + preflight()?; + commit_replacement(&temp, path, expected.identity.is_some()) + })(); + if result.is_err() && owned_temporary { + // A rejected preflight owns no file. If the storage path changed after + // creation, retain evidence instead of deleting through that new path. + preflight().map_err(|_| "plugin.settings.recoveryRequired")?; + match std::fs::remove_file(&temp) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err("plugin.settings.recoveryRequired".into()), + } + } + result +} + +fn commit_replacement(source: &Path, destination: &Path, replacing: bool) -> Result<(), String> { + #[cfg(windows)] + if replacing { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, ReplaceFileW, MOVEFILE_WRITE_THROUGH, + }; + let backup = source.with_extension("backup"); + if backup.exists() { + return Err("plugin.settings.writeFailed".into()); + } + let destination_wide: Vec = destination + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect(); + let source_wide: Vec = source.as_os_str().encode_wide().chain(Some(0)).collect(); + let backup_wide: Vec = backup.as_os_str().encode_wide().chain(Some(0)).collect(); + // ReplaceFile retains the original file's ACL and other metadata. Do + // not fall back to a rename that could discard those permissions. + let result = unsafe { + ReplaceFileW( + destination_wide.as_ptr(), + source_wide.as_ptr(), + backup_wide.as_ptr(), + 0, + std::ptr::null(), + std::ptr::null(), + ) + }; + if result != 0 { + return std::fs::remove_file(&backup).map_err(|_| "plugin.settings.writeFailed".into()); + } + // Windows can move the old file before reporting a replacement error. + // Keep its backup unless the old source can be restored without + // overwriting another writer. Never delete the only surviving bytes. + if backup.exists() { + // Omit REPLACE_EXISTING so recovery cannot overwrite a concurrent + // writer that created the destination after the failed replace. + if unsafe { + MoveFileExW( + backup_wide.as_ptr(), + destination_wide.as_ptr(), + MOVEFILE_WRITE_THROUGH, + ) + } != 0 + { + return Err("plugin.settings.writeFailed".into()); + } + return Err("plugin.settings.recoveryRequired".into()); + } + return Err("plugin.settings.writeFailed".into()); + } + #[cfg(not(windows))] + let _ = replacing; + crate::logging::replace_file_atomically(source, destination) + .map_err(|_| "plugin.settings.writeFailed".into()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SettingSnapshot { + pub manifest: Value, + pub settings: Vec, + pub setting_actions: Vec, + pub global_values: BTreeMap, + pub project_values: BTreeMap, + pub resolved_global: Value, + pub resolved_project: Value, + pub global_path: String, + pub project_path: Option, + pub identity: String, + pub package_identity: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reload_context: Option, + #[serde(skip)] + global_store: Store, + #[serde(skip)] + project_store: Store, +} + +fn workspace(workspace: &str) -> Result<(), String> { + if workspace.is_empty() { + return Ok(()); + } + let root = Path::new(workspace); + if !root.is_absolute() || !root.is_dir() { + return Err("plugin.settings.invalidWorkspace".into()); + } + // Do not follow workspace state links into another project or installation. + for path in [root.join(".lithe"), project_store_path(workspace)] { + match std::fs::symlink_metadata(&path) { + Ok(metadata) => { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return Err("plugin.settings.readOnlySource".into()); + } + } + #[cfg(not(windows))] + if metadata.file_type().is_symlink() { + return Err("plugin.settings.readOnlySource".into()); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err("plugin.settings.readFailed".into()), + } + } + Ok(()) +} + +fn resolved( + manifest: &Value, + global: &BTreeMap, + project: &BTreeMap, +) -> Result { + let response = super::super::scan::core_execute( + "plugins.declarative.resolveSettings", + json!({ + "manifest": manifest, "settings": global, "projectSettings": project, + }), + )?; + if response["status"] != "ok" { + return Err("plugin.settings.invalidDeclaration".into()); + } + Ok(response["settings"].clone()) +} + +fn snapshot(app: &AppHandle, plugin: &str, root: &str) -> Result { + let package = super::super::launch::verified_package_by_id(app, plugin)?; + snapshot_package(app, plugin, root, package) +} + +fn snapshot_package( + app: &AppHandle, + plugin: &str, + root: &str, + package: super::super::ScannedPackage, +) -> Result { + workspace(root)?; + let global_path = global_store_path(app, plugin)?; + for path in [ + Some(global_path.as_path()), + (!root.is_empty()).then(|| Path::new(root)), + ] + .into_iter() + .flatten() + { + // Missing preference directories inherit their nearest existing + // ancestor's location. This also catches redirected app-data roots. + let existing = path + .ancestors() + .find(|ancestor| ancestor.exists()) + .ok_or("plugin.settings.readFailed")?; + let canonical = existing + .canonicalize() + .map_err(|_| "plugin.settings.readFailed")?; + if app + .path() + .resource_dir() + .ok() + .and_then(|path| path.canonicalize().ok()) + .is_some_and(|path| canonical.starts_with(path)) + || std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf)) + .and_then(|path| path.canonicalize().ok()) + .is_some_and(|path| canonical.starts_with(path)) + { + return Err("plugin.settings.readOnlySource".into()); + } + } + let normalized = super::super::scan::validate_manifest_with( + &package.manifest, + Some(super::super::host_capabilities()), + ) + .map_err(|_| "plugin.settings.invalidDeclaration")?; + let settings = normalized["settings"] + .as_array() + .cloned() + .unwrap_or_default(); + let global_store = read_store(&global_path)?; + let project_store = if root.is_empty() { + Store { + values: Map::new(), + identity: None, + } + } else { + read_store(&project_store_path(root))? + }; + let global: BTreeMap = global_store.values.clone().into_iter().collect(); + let project: BTreeMap = match project_store.values.get(plugin) { + Some(Value::Object(values)) => values.clone().into_iter().collect(), + Some(_) => return Err("plugin.settings.corruptStore".into()), + None => BTreeMap::new(), + }; + let digest = super::super::signature::content_digest(&package.root) + .map_err(|error| error.code().to_string())?; + let package_identity = format!( + "{:x}", + Sha256::digest( + serde_json::to_vec(&json!({ + "plugin": plugin, "manifest": normalized, "digest": digest, + })) + .map_err(|error| error.to_string())? + ) + ); + let identity = format!( + "{:x}", + Sha256::digest( + serde_json::to_vec(&json!({ + "plugin": plugin, "manifest": normalized, "digest": digest, "workspace": root, + "globalBytes": global_store.identity, "projectBytes": project_store.identity, + })) + .map_err(|error| error.to_string())? + ) + ); + // Unknown keys may belong to an older plugin version; preserve them on + // disk, but never send unrelated values to ordinary settings controls. + let declared = |id: &str| settings.iter().any(|setting| setting["id"] == id); + Ok(SettingSnapshot { + resolved_global: resolved(&normalized, &global, &BTreeMap::new())?, + resolved_project: resolved(&normalized, &global, &project)?, + global_values: global.into_iter().filter(|(id, _)| declared(id)).collect(), + project_values: project.into_iter().filter(|(id, _)| declared(id)).collect(), + settings, + setting_actions: normalized["settingActions"] + .as_array() + .cloned() + .unwrap_or_default(), + manifest: normalized, + global_path: global_store_path(app, plugin)? + .to_string_lossy() + .into_owned(), + project_path: (!root.is_empty()) + .then(|| project_store_path(root).to_string_lossy().into_owned()), + identity, + package_identity, + reload_context: None, + global_store, + project_store, + }) +} + +/// Only the owner of the mutation barrier may read while launches are +/// revoked. Discovery rechecks the acceptance record and live signature; +/// settings are read again without re-enabling the plugin to get a lease. +pub(super) fn reload( + app: &AppHandle, + mutation: &super::super::lifecycle::Mutation, + root: &str, + expected: &str, + expected_package: &str, +) -> Result { + let plugin = mutation.plugin_id(); + let _write = WRITES.lock().map_err(|_| "plugin.settings.unavailable")?; + let host = app.state::(); + publish_reload(&host, plugin, expected, expected_package, || { + let fresh = super::super::scan::build_catalog(app)? + .into_iter() + .find(|package| { + package.entry.plugin_id == plugin + && package.entry.enabled + && package.entry.status == "accepted" + }) + .ok_or("plugin.settings.reloadRejected")?; + let next = snapshot_package(app, plugin, root, fresh.clone())?; + Ok((fresh, next)) + }) +} + +// Publish only the reverified target, preserving concurrent changes to other +// plugins. Settings conflicts may resume after this fresh catalog is visible; +// changed package bytes must stay revoked until explicit package recovery. +fn publish_reload( + host: &super::super::PluginHostState, + plugin: &str, + expected: &str, + expected_package: &str, + verified: impl FnOnce() -> Result<(super::super::ScannedPackage, SettingSnapshot), String>, +) -> Result { + let mut result = None; + host.refresh(|| { + let (fresh, next) = verified()?; + if fresh.entry.plugin_id != plugin + || !fresh.entry.enabled + || fresh.entry.status != "accepted" + { + return Err("plugin.settings.reloadRejected".into()); + } + if next.package_identity != expected_package { + return Err("plugin.settings.packageChanged".into()); + } + let mut packages = host.packages(); + let entry = packages + .iter_mut() + .find(|package| package.entry.plugin_id == plugin) + .ok_or("plugin.settings.reloadRejected")?; + *entry = fresh; + result = Some(if next.identity == expected { + Ok(next) + } else { + Err("plugin.settings.conflict".into()) + }); + Ok(packages) + })?; + result.ok_or_else(|| "plugin.settings.reloadRejected".to_string())? +} + +pub(crate) fn read( + app: &AppHandle, + plugin_id: &str, + workspace_path: &str, +) -> Result { + let _lease = app + .state::() + .acquire(plugin_id)?; + let _write = WRITES.lock().map_err(|_| "plugin.settings.unavailable")?; + snapshot(app, plugin_id, workspace_path) +} + +pub(super) fn store( + app: &AppHandle, + plugin: &str, + id: &str, + scope: &str, + root: &str, + value: Option, + expected: &str, +) -> Result { + let _lease = app + .state::() + .acquire(plugin)?; + let _write = WRITES.lock().map_err(|_| "plugin.settings.unavailable")?; + let before = snapshot(app, plugin, root)?; + if before.identity != expected { + return Err("plugin.settings.conflict".into()); + } + validate_change( + &before.manifest, + &before.settings, + id, + scope, + root, + value.as_ref(), + )?; + // Keep an explicit empty/default distinct from erasing an override. + let (path, source) = if scope == "project" { + (project_store_path(root), &before.project_store) + } else { + (global_store_path(app, plugin)?, &before.global_store) + }; + let mut values = source.values.clone(); + let target = if scope == "project" { + values + .entry(plugin.to_string()) + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or("plugin.settings.corruptStore")? + } else { + &mut values + }; + if let Some(value) = value { + target.insert(id.to_string(), value); + } else { + target.remove(id); + } + if scope == "project" + && values + .get(plugin) + .and_then(Value::as_object) + .is_some_and(Map::is_empty) + { + values.remove(plugin); + } + write_guarded(&path, &values, source)?; + snapshot(app, plugin, root) +} + +fn validate_change( + manifest: &Value, + settings: &[Value], + id: &str, + scope: &str, + root: &str, + value: Option<&Value>, +) -> Result<(), String> { + let declaration = settings + .iter() + .find(|setting| setting["id"] == id) + .ok_or("plugin.settings.unknownSetting")?; + if scope != "global" + && (scope != "project" || root.is_empty() || declaration["scope"] != "project") + { + return Err("plugin.settings.invalidScope".into()); + } + if let Some(value) = value { + // Ask Core whether this explicit value is accepted; a fallback to a + // default is not successful validation. No copied setting validator. + let candidate = BTreeMap::from([(id.to_string(), value.clone())]); + let empty = BTreeMap::new(); + let values = if scope == "project" { + resolved(manifest, &empty, &candidate)? + } else { + resolved(manifest, &candidate, &empty)? + }; + if !values.as_array().is_some_and(|values| { + values + .iter() + .any(|item| item["id"] == id && item["source"] == scope && item["value"] == *value) + }) { + return Err("plugin.settings.invalidValue".into()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/windows/tauri/src-tauri/src/plugin_host/settings/snapshot/tests.rs b/windows/tauri/src-tauri/src/plugin_host/settings/snapshot/tests.rs new file mode 100644 index 0000000000..bd98c01e17 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/settings/snapshot/tests.rs @@ -0,0 +1,365 @@ +use super::*; +use std::sync::atomic::{AtomicU64, Ordering}; + +struct TempRoot(PathBuf); +impl TempRoot { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "lithe-settings-snapshot-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&path).expect("isolated settings root"); + Self(path) + } + fn file(&self) -> PathBuf { + self.0.join("settings.json") + } +} +impl Drop for TempRoot { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).expect("remove isolated settings root"); + } +} + +#[test] +fn missing_snapshot_is_read_only_and_empty_override_round_trips() { + let root = TempRoot::new(); + let path = root.file(); + let before = read_store(&path).expect("missing is inherited"); + assert!(before.values.is_empty()); + assert!(before.identity.is_none()); + assert!(!path.exists(), "opening settings must not create defaults"); + let values = json!({"text": "", "checks": []}) + .as_object() + .unwrap() + .clone(); + write_guarded(&path, &values, &before).expect("explicit empty overrides"); + let after = read_store(&path).expect("read back actual bytes"); + assert_eq!(after.values, values); + assert!(after.identity.is_some()); + assert_eq!( + std::fs::read_dir(&root.0).unwrap().count(), + 1, + "no temporary files" + ); +} + +#[test] +fn external_edits_including_formatting_reject_the_old_snapshot() { + let root = TempRoot::new(); + let path = root.file(); + std::fs::write(&path, r#"{"other": 2}"#).unwrap(); + let before = read_store(&path).unwrap(); + std::fs::write(&path, "{\n \"other\": 2\n}\n").unwrap(); + let external = std::fs::read(&path).unwrap(); + assert_eq!( + write_guarded(&path, &Map::new(), &before).unwrap_err(), + "plugin.settings.conflict" + ); + assert_eq!(std::fs::read(&path).unwrap(), external); + assert_eq!(std::fs::read_dir(&root.0).unwrap().count(), 1); +} + +#[test] +fn guarded_write_preserves_unrelated_values_and_corrupt_sources() { + let root = TempRoot::new(); + let path = root.file(); + std::fs::write( + &path, + r#"{"other.plugin":{"opaque":"unchanged"},"example.plugin":{"option":1}}"#, + ) + .unwrap(); + let before = read_store(&path).unwrap(); + let mut values = before.values.clone(); + values.remove("example.plugin"); + write_guarded(&path, &values, &before).unwrap(); + assert_eq!( + read_store(&path).unwrap().values["other.plugin"], + json!({"opaque":"unchanged"}) + ); + std::fs::write(&path, "not JSON").unwrap(); + assert!(read_store(&path).is_err()); + assert!(write_guarded(&path, &Map::new(), &before).is_err()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "not JSON"); +} + +#[test] +fn core_validation_rejects_unknown_scope_and_invalid_values() { + let manifest: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/contracts/plugins/v1/fixtures/settings-v2.plugin.json" + ))) + .unwrap(); + let settings = manifest["settings"].as_array().unwrap(); + assert_eq!( + validate_change( + &manifest, + settings, + "unknown", + "global", + "", + Some(&json!(true)) + ) + .unwrap_err(), + "plugin.settings.unknownSetting" + ); + assert_eq!( + validate_change( + &manifest, + settings, + "checks", + "project", + "workspace", + Some(&json!([])) + ) + .unwrap_err(), + "plugin.settings.invalidScope" + ); + assert_eq!( + validate_change( + &manifest, + settings, + "dialyzer", + "project", + "", + Some(&json!(true)) + ) + .unwrap_err(), + "plugin.settings.invalidScope" + ); + assert_eq!( + validate_change( + &manifest, + settings, + "mixEnv", + "global", + "", + Some(&json!("--unsafe")) + ) + .unwrap_err(), + "plugin.settings.invalidValue" + ); + validate_change( + &manifest, + settings, + "dialyzer", + "project", + "workspace", + Some(&json!(false)), + ) + .unwrap(); + validate_change( + &manifest, + settings, + "checks", + "global", + "", + Some(&json!([])), + ) + .unwrap(); + validate_change(&manifest, settings, "mixEnv", "global", "", None).unwrap(); +} + +#[test] +fn core_resolves_all_effects_without_conflating_empty_project_values_and_inheritance() { + let mut manifest: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/contracts/plugins/v1/fixtures/settings-v2.plugin.json" + ))) + .unwrap(); + manifest["settings"].as_array_mut().unwrap().push(json!({ + "id":"modelLabel", "type":"text", "title":"Model label", "default":"default-label", "pattern":".*", + "scope":"project", "effect":"reload" + })); + let global = BTreeMap::from([ + ("modelLabel".into(), json!("global-label")), + ("dialyzer".into(), json!(true)), + ("mixEnv".into(), json!("test")), + ("checks".into(), json!([])), + ]); + let project = BTreeMap::from([ + ("modelLabel".into(), json!("")), + ("dialyzer".into(), json!(false)), + ]); + let effective = resolved(&manifest, &global, &project).unwrap(); + let value = |id: &str| { + effective + .as_array() + .unwrap() + .iter() + .find(|item| item["id"] == id) + .unwrap() + }; + assert_eq!(value("modelLabel")["value"], ""); + assert_eq!(value("modelLabel")["source"], "project"); + assert_eq!(value("dialyzer")["value"], false); + assert_eq!(value("mixEnv")["value"], "test"); + assert_eq!(value("checks")["value"], json!([])); + let inherited = resolved(&manifest, &global, &BTreeMap::new()).unwrap(); + let label = inherited + .as_array() + .unwrap() + .iter() + .find(|item| item["id"] == "modelLabel") + .unwrap(); + assert_eq!(label["value"], "global-label"); + assert_eq!(label["source"], "global"); + assert_eq!( + manifest["settings"] + .as_array() + .unwrap() + .iter() + .map(|item| item["effect"].as_str().unwrap()) + .collect::>(), + vec!["immediate", "restart", "nextRun", "reload"] + ); +} + +#[test] +fn read_only_source_remains_readable_but_cannot_be_replaced() { + struct RestorePermissions(PathBuf, std::fs::Permissions); + impl Drop for RestorePermissions { + fn drop(&mut self) { + std::fs::set_permissions(&self.0, self.1.clone()).expect("restore test permissions"); + } + } + let root = TempRoot::new(); + let path = root.file(); + std::fs::write(&path, r#"{"option":"inherited"}"#).unwrap(); + let original = std::fs::metadata(&path).unwrap().permissions(); + let _restore = RestorePermissions(path.clone(), original.clone()); + let mut read_only = original; + read_only.set_readonly(true); + std::fs::set_permissions(&path, read_only).unwrap(); + let before = read_store(&path).expect("read-only values are still discoverable"); + assert_eq!(before.values["option"], "inherited"); + assert_eq!( + write_guarded(&path, &Map::new(), &before).unwrap_err(), + "plugin.settings.readOnlySource" + ); + assert_eq!(read_store(&path).unwrap().identity, before.identity); +} + +#[test] +fn pending_recovery_cannot_be_mistaken_for_missing_defaults() { + let root = TempRoot::new(); + let path = root.file(); + std::fs::write( + root.0.join("settings.json.123-0.backup"), + r#"{"option":"original"}"#, + ) + .unwrap(); + assert_eq!( + read_store(&path).err().unwrap(), + "plugin.settings.recoveryRequired" + ); + assert!(!path.exists()); +} + +fn reload_fixture( + plugin: &str, + identity: &str, + package_identity: &str, +) -> (super::super::super::ScannedPackage, SettingSnapshot) { + let package = super::super::super::ScannedPackage { + entry: super::super::super::CatalogEntry { + plugin_id: plugin.into(), + display_name: plugin.into(), + version: "1.0.0".into(), + api_version: 1, + source: "installed", + enabled: true, + status: "accepted", + languages: vec![], + declared_settings: vec![], + declares_tests: false, + primary_language_id: None, + rejection: None, + }, + root: PathBuf::from("fixture-package"), + manifest: json!({"id": plugin, "marker": identity}), + trust: None, + }; + let store = Store { + values: Map::new(), + identity: None, + }; + let snapshot = SettingSnapshot { + manifest: package.manifest.clone(), + settings: vec![], + setting_actions: vec![], + global_values: BTreeMap::new(), + project_values: BTreeMap::new(), + resolved_global: json!([]), + resolved_project: json!([]), + global_path: "fixture-preferences.json".into(), + project_path: None, + identity: identity.into(), + package_identity: package_identity.into(), + reload_context: None, + global_store: store.clone(), + project_store: store, + }; + (package, snapshot) +} + +#[test] +fn global_reload_publishes_reverified_target_and_preserves_other_plugins() { + let host = super::super::super::PluginHostState::default(); + let old = reload_fixture("target.plugin", "old", "approved").0; + let other = reload_fixture("other.plugin", "unrelated", "other-approved").0; + host.set_packages(vec![old, other]); + let result = publish_reload(&host, "target.plugin", "saved", "approved", || { + Ok(reload_fixture("target.plugin", "saved", "approved")) + }) + .expect("saved settings and approved package reload"); + assert_eq!(result.identity, "saved"); + let catalog = host.packages(); + assert_eq!(catalog[0].manifest["marker"], "saved"); + assert_eq!(catalog[1].manifest["marker"], "unrelated"); +} + +#[test] +fn settings_conflict_resumes_only_after_the_reverified_catalog_is_published() { + let host = super::super::super::PluginHostState::default(); + host.set_packages(vec![reload_fixture("target.plugin", "old", "approved").0]); + let result = publish_reload(&host, "target.plugin", "saved", "approved", || { + Ok(reload_fixture( + "target.plugin", + "external-settings", + "approved", + )) + }); + assert_eq!(result.err().unwrap(), "plugin.settings.conflict"); + assert_eq!(host.packages()[0].manifest["marker"], "external-settings"); +} + +#[test] +fn changed_or_rejected_packages_preserve_the_old_catalog_until_recovery() { + let host = super::super::super::PluginHostState::default(); + host.set_packages(vec![reload_fixture("target.plugin", "old", "approved").0]); + let changed = publish_reload(&host, "target.plugin", "saved", "approved", || { + Ok(reload_fixture("target.plugin", "saved", "different-bytes")) + }); + assert_eq!(changed.err().unwrap(), "plugin.settings.packageChanged"); + let rejected = publish_reload(&host, "target.plugin", "saved", "approved", || { + let (mut package, snapshot) = reload_fixture("target.plugin", "saved", "approved"); + package.entry.status = "rejected"; + Ok((package, snapshot)) + }); + assert_eq!(rejected.err().unwrap(), "plugin.settings.reloadRejected"); + let disabled = publish_reload(&host, "target.plugin", "saved", "approved", || { + let (mut package, snapshot) = reload_fixture("target.plugin", "saved", "approved"); + package.entry.enabled = false; + Ok((package, snapshot)) + }); + assert_eq!(disabled.err().unwrap(), "plugin.settings.reloadRejected"); + assert_eq!(host.packages()[0].manifest["marker"], "old"); + let failed = publish_reload(&host, "target.plugin", "saved", "approved", || { + Err("signature failed".into()) + }); + assert_eq!(failed.err().unwrap(), "signature failed"); + assert_eq!(host.packages()[0].manifest["marker"], "old"); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/signature.rs b/windows/tauri/src-tauri/src/plugin_host/signature.rs new file mode 100644 index 0000000000..55ce94409f --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/signature.rs @@ -0,0 +1,967 @@ +//! Publisher signature and content-digest verification for plugin packages. +//! +//! Byte-compatible with the macOS `PluginPackageSignature` (CryptoKit): the +//! canonical payload lines, UTF-8 base64url tokens, file-path sorting, and +//! the content digest follow the same specification, so one signed package +//! verifies identically on both platforms. Package bytes are hashed as UTF-8 +//! relative paths with `/` separators; names that are not valid UTF-8 (which +//! Windows can produce) cannot receive a token and are refused. +//! +//! Trust policy lives here, not in the package: the host pins the signature +//! requirement of every official plugin ID, so a package cannot downgrade it +//! by declaring `vendor.signatureRequirement` itself, and `sameTeamAsHost` is +//! only honored for packages that ship inside the host's own installation. + +use base64::Engine; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::OnceLock; + +/// Signature document file name inside a package root; excluded from every +/// digest computation, and its presence forces verification. +pub const SIGNATURE_FILE_NAME: &str = "lithe-plugin-signature.json"; + +/// Signature schema version; must stay equal to the macOS implementation. +pub const SCHEMA_VERSION: u32 = 1; +pub const ALGORITHM: &str = "ed25519"; +/// Key ID used for newly signed official packages; same as macOS. +pub const OFFICIAL_KEY_ID: &str = "lithe-official-plugins-v2"; +/// Standard-base64 Ed25519 verification key for [`OFFICIAL_KEY_ID`]. This is +/// a public key, not a secret; the private signing key never enters this +/// repository. +pub const OFFICIAL_PUBLIC_KEY_BASE64: &str = "yTjUaLpCNXXEQDuan5Ymw5rCtR0JeTNmh6hyTfOFjTs="; + +const STANDARD_BASE64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; +const BASE64URL_NO_PAD: base64::engine::GeneralPurpose = + base64::engine::general_purpose::URL_SAFE_NO_PAD; + +/// The signature document stored in `lithe-plugin-signature.json`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SignatureDocument { + pub schema_version: u32, + pub algorithm: String, + pub key_id: String, + pub plugin_id: String, + pub plugin_version: String, + /// Relative package path (UTF-8, `/` separators) to lowercase hex SHA-256. + pub files: BTreeMap, + /// Standard-base64 Ed25519 signature over the canonical payload. + pub signature: String, +} + +/// Every rejection this module produces, with the stable code the catalog +/// reports for a quarantined package. +#[derive(Debug, Clone, PartialEq)] +pub enum TrustError { + /// The package (or its origin) claims no publisher signature, but the + /// host only trusts unsigned community packages that are not pinned + /// official plugins. + UnsignedNotAllowed, + /// The trust policy requires a publisher signature and none is present. + SignatureMissing, + /// A signature document exists but is malformed, does not match the + /// package, or does not verify. + SignatureInvalid(String), + /// The document names a key ID this host does not trust. + UntrustedKey(String), + /// The package files differ from the digest recorded when the user + /// approved it (changed, added, or removed files). + ContentChanged, + /// An installed package record carries no content digest, so its bytes + /// cannot be proven to be what the user approved (schema 1 migration). + ProvenanceMissing, + /// A `sameTeamAsHost` package outside the host's own installation. + HostSealRequired, + /// The manifest's `vendor.signatureRequirement` is not a known value. + InvalidPolicy, +} + +impl TrustError { + /// Stable error code for catalog quarantines and diagnostics. + pub fn code(&self) -> &'static str { + match self { + TrustError::UnsignedNotAllowed => "plugin.trust.unsignedNotAllowed", + TrustError::SignatureMissing => "plugin.trust.signatureMissing", + TrustError::SignatureInvalid(_) => "plugin.trust.signatureInvalid", + TrustError::UntrustedKey(_) => "plugin.trust.untrustedKey", + TrustError::ContentChanged => "plugin.trust.contentChanged", + TrustError::ProvenanceMissing => "plugin.trust.provenanceMissing", + TrustError::HostSealRequired => "plugin.trust.hostSealRequired", + TrustError::InvalidPolicy => "plugin.trust.invalidPolicy", + } + } + + pub fn message(&self) -> String { + match self { + TrustError::UnsignedNotAllowed => { + "the package is unsigned and this host only loads unsigned community packages \ + after user confirmation" + .to_string() + } + TrustError::SignatureMissing => { + "the package requires a publisher signature but none is present".to_string() + } + TrustError::SignatureInvalid(detail) => { + format!("the package signature is invalid: {detail}") + } + TrustError::UntrustedKey(key_id) => format!( + "the package is signed by key {key_id}, which this version of Lithe does not trust" + ), + TrustError::ContentChanged => { + "the package differs from the contents the user approved".to_string() + } + TrustError::ProvenanceMissing => { + "the install record has no content digest; reinstall the plugin to approve it \ + with the current trust rules" + .to_string() + } + TrustError::HostSealRequired => { + "the package can only be trusted when it ships inside Lithe".to_string() + } + TrustError::InvalidPolicy => { + "the package declares an unknown signature requirement".to_string() + } + } + } +} + +/// How a package proves its origin; mirrors the macOS trust model. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum InstallOrigin { + /// Ships inside the host installation; sealed by distribution, not by a + /// package signature. + Bundled, + /// Downloaded from a marketplace source as a publisher-signed package. + Marketplace, + /// An unsigned package the user installed after confirming its + /// permissions and programs; integrity comes from the recorded digest. + Community, +} + +/// Signature requirement of a package: the pinned host policy for official +/// plugin IDs, otherwise the manifest's own `vendor.signatureRequirement`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SignatureRequirement { + /// Trusted only inside the host's own installation. + SameTeamAsHost, + /// Must carry a valid publisher signature over every file. + PublisherPackage, + /// No Lithe signature; community trust only. + Unsigned, +} + +/// Trust policy the host pins per official plugin ID. The pinned policy wins +/// over whatever the package declares, so an official ID cannot downgrade the +/// signature it must carry. This mirrors the macOS `OfficialPluginCatalog` +/// for the IDs that exist on Windows; the bundled PHP contract package ships +/// inside the installer, so it is pinned `sameTeamAsHost`. +pub fn official_signature_requirement(plugin_id: &str) -> Option { + match plugin_id { + "dev.lithe.plugin.php-support" => Some(SignatureRequirement::SameTeamAsHost), + "dev.lithe.plugin.java-support" => Some(SignatureRequirement::SameTeamAsHost), + "dev.lithe.plugin.go-support" => Some(SignatureRequirement::PublisherPackage), + _ => None, + } +} + +/// Parses the manifest's `vendor.signatureRequirement`. +pub fn declared_signature_requirement( + manifest: &serde_json::Value, +) -> Result { + match manifest["vendor"]["signatureRequirement"].as_str() { + Some("sameTeamAsHost") => Ok(SignatureRequirement::SameTeamAsHost), + Some("publisherPackage") => Ok(SignatureRequirement::PublisherPackage), + Some("none") => Ok(SignatureRequirement::Unsigned), + _ => Err(TrustError::InvalidPolicy), + } +} + +/// The host's trust keys, keyed by key ID. A malformed embedded key is a +/// build defect and must never be silently skipped. +pub fn trusted_keys() -> BTreeMap { + #[cfg(test)] + if let Some(keys) = test_trusted_keys() { + return keys; + } + OFFICIAL_TRUSTED_KEYS + .get_or_init(|| { + let bytes = STANDARD_BASE64 + .decode(OFFICIAL_PUBLIC_KEY_BASE64) + .expect("embedded official plugin signing key must be valid base64"); + let key = VerifyingKey::from_bytes( + &bytes + .try_into() + .expect("embedded official plugin signing key must be 32 bytes"), + ) + .expect("embedded official plugin signing key must be a valid Ed25519 key"); + BTreeMap::from([(OFFICIAL_KEY_ID.to_string(), key)]) + }) + .clone() +} + +static OFFICIAL_TRUSTED_KEYS: OnceLock> = OnceLock::new(); + +#[cfg(test)] +thread_local! { + static TEST_TRUSTED_KEYS: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +/// Runs `run` with the given keys trusted by the host. Production builds +/// never see this seam; the signature vectors' RFC 8032 key must never trust +/// a real package. +#[cfg(test)] +pub(crate) fn with_trusted_keys_for_tests( + keys: BTreeMap, + run: impl FnOnce() -> R, +) -> R { + TEST_TRUSTED_KEYS.with(|cell| { + let previous = cell.borrow_mut().replace(keys); + let result = run(); + *cell.borrow_mut() = previous; + result + }) +} + +#[cfg(test)] +fn test_trusted_keys() -> Option> { + TEST_TRUSTED_KEYS.with(|cell| cell.borrow().clone()) +} + +/// The RFC 8032 test signing key shared with `signature-vectors.json`. +#[cfg(test)] +pub(crate) fn rfc8032_signing_key_for_tests() -> ed25519_dalek::SigningKey { + fn hex_to_bytes(hex: &str) -> Vec { + (0..hex.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&hex[index..index + 2], 16).expect("hex byte")) + .collect() + } + let mut seed = [0u8; 32]; + seed.copy_from_slice(&hex_to_bytes( + "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + )); + ed25519_dalek::SigningKey::from_bytes(&seed) +} + +/// True when the package root carries a signature document. +pub fn has_signature_file(package_root: &Path) -> bool { + package_root.join(SIGNATURE_FILE_NAME).is_file() +} + +/// Reads and parses the signature document of a package root. +pub fn read_signature_document(package_root: &Path) -> Result { + let path = package_root.join(SIGNATURE_FILE_NAME); + let text = std::fs::read_to_string(&path).map_err(|_| TrustError::SignatureMissing)?; + serde_json::from_str(&text) + .map_err(|error| TrustError::SignatureInvalid(format!("unreadable document: {error}"))) +} + +/// UTF-8 base64url token without padding, exactly as the macOS signer emits. +fn token(value: &str) -> String { + BASE64URL_NO_PAD.encode(value.as_bytes()) +} + +/// Key IDs are written raw into the signed payload to keep existing +/// signatures byte-compatible, so they are restricted to a safe alphabet. +pub fn is_valid_key_id(key_id: &str) -> bool { + !key_id.is_empty() + && key_id + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '-' | '_')) +} + +/// The exact bytes signed by the publisher, identical to the macOS +/// implementation: header lines, then one line per file in ascending path +/// order (UTF-8 byte order, which matches the macOS scalar order for the NFC +/// names packages must use), every line terminated by `\n`. +pub fn canonical_payload( + key_id: &str, + plugin_id: &str, + plugin_version: &str, + files: &BTreeMap, +) -> Vec { + let mut payload = format!( + "schemaVersion={SCHEMA_VERSION}\nalgorithm={ALGORITHM}\nkeyID={key_id}\npluginID={}\npluginVersion={}\n", + token(plugin_id), + token(plugin_version) + ); + for (path, digest) in files { + payload.push_str(&format!("file={}={}\n", token(path), digest)); + } + payload.into_bytes() +} + +/// SHA-256 over `token(path)=digest\n` for every file in ascending path +/// order. Recorded when an unsigned package is installed and rechecked on +/// every load, so a changed, added, or removed file is detected. +pub fn content_digest(package_root: &Path) -> Result { + let digests = file_digests(package_root)?; + let lines: String = digests + .iter() + .map(|(path, digest)| format!("{}={digest}\n", token(path))) + .collect(); + Ok(hex(&Sha256::digest(lines.as_bytes()))) +} + +/// Verifies the signature document of a package when one is present. A +/// present document is always verified — even for packages trusted through +/// another route — so a forged or damaged document is never ignored. +pub fn verify_signature_if_present( + package_root: &Path, + plugin_id: &str, + plugin_version: &str, + trusted_keys: &BTreeMap, +) -> Result<(), TrustError> { + if !has_signature_file(package_root) { + return Ok(()); + } + let document = read_signature_document(package_root)?; + verify_document( + package_root, + plugin_id, + plugin_version, + &document, + trusted_keys, + ) +} + +/// Install-time policy check for a package about to be recorded as `origin`. +/// Same rules as [`verify_for_origin`], without the content-digest +/// requirement for community packages: the digest is being created by this +/// install and cannot be checked against a record that does not exist yet. +pub fn verify_for_install( + package_root: &Path, + plugin_id: &str, + plugin_version: &str, + manifest: &serde_json::Value, + origin: InstallOrigin, + bundled_root: Option<&Path>, + trusted_keys: &BTreeMap, +) -> Result<(), TrustError> { + let declared = declared_signature_requirement(manifest)?; + let pinned = official_signature_requirement(plugin_id).unwrap_or(declared); + // The pinned host policy decides which channel the package may use; the + // package cannot pick a weaker one by declaring it. + match pinned { + SignatureRequirement::Unsigned => { + // Unsigned trust is community-only: the user's explicit review of + // the exact bytes, never a pinned official ID or another origin. + if origin != InstallOrigin::Community { + return Err(TrustError::UnsignedNotAllowed); + } + verify_signature_if_present(package_root, plugin_id, plugin_version, trusted_keys) + } + SignatureRequirement::PublisherPackage => { + // A publisher signature is required and verified whatever channel + // delivered the package: a marketplace download, or a signed disk + // import recorded as community provenance. + if !has_signature_file(package_root) { + return Err(TrustError::SignatureMissing); + } + verify_signature_if_present(package_root, plugin_id, plugin_version, trusted_keys) + } + SignatureRequirement::SameTeamAsHost => { + // Trusted only inside the host's own installation, proven by real + // containment in the trusted bundled root - never by a record's + // origin claim, and never by a publisher signature alone. + verify_signature_if_present(package_root, plugin_id, plugin_version, trusted_keys)?; + verify_bundled_seal(package_root, origin, bundled_root) + } + } +} + +/// Proves a `sameTeamAsHost` package really lives inside the host's own +/// installation: the origin must be bundled and the package root must sit +/// inside the trusted resource root the discoverer reported. +fn verify_bundled_seal( + package_root: &Path, + origin: InstallOrigin, + bundled_root: Option<&Path>, +) -> Result<(), TrustError> { + if origin != InstallOrigin::Bundled { + return Err(TrustError::HostSealRequired); + } + let Some(bundled_root) = bundled_root else { + return Err(TrustError::HostSealRequired); + }; + let bundled = bundled_root + .canonicalize() + .map_err(|_| TrustError::HostSealRequired)?; + let package = package_root + .canonicalize() + .map_err(|_| TrustError::HostSealRequired)?; + if package.starts_with(&bundled) { + Ok(()) + } else { + Err(TrustError::HostSealRequired) + } +} + +/// Full load-time trust check: the host policy decides what the package must +/// prove, the package cannot downgrade it. +/// +/// `expected_content_digest` is the digest recorded when the user approved an +/// unsigned community package; load paths must pass it for community origin, +/// install paths use [`verify_for_install`] while they are creating the +/// record. +pub fn verify_for_origin( + package_root: &Path, + plugin_id: &str, + plugin_version: &str, + manifest: &serde_json::Value, + origin: InstallOrigin, + expected_content_digest: Option<&str>, + bundled_root: Option<&Path>, + trusted_keys: &BTreeMap, +) -> Result<(), TrustError> { + verify_for_install( + package_root, + plugin_id, + plugin_version, + manifest, + origin, + bundled_root, + trusted_keys, + )?; + // Signed or not, the load path rechecks the package against the digest + // recorded when its bytes were approved; a publisher signature proves + // who released the bytes, not that these bytes are what the user saw. + if matches!( + origin, + InstallOrigin::Community | InstallOrigin::Marketplace + ) { + let expected = expected_content_digest.ok_or(TrustError::ProvenanceMissing)?; + let actual = content_digest(package_root)?; + if actual != expected { + return Err(TrustError::ContentChanged); + } + } + Ok(()) +} + +/// Verifies one signature document against the live package bytes. +pub fn verify_document( + package_root: &Path, + plugin_id: &str, + plugin_version: &str, + document: &SignatureDocument, + trusted_keys: &BTreeMap, +) -> Result<(), TrustError> { + let Some(public_key) = trusted_keys.get(&document.key_id) else { + return Err(TrustError::UntrustedKey(document.key_id.clone())); + }; + let signature_bytes = STANDARD_BASE64 + .decode(document.signature.as_bytes()) + .map_err(|_| TrustError::SignatureInvalid("signature is not base64".to_string()))?; + if document.schema_version != SCHEMA_VERSION + || document.algorithm != ALGORITHM + || !is_valid_key_id(&document.key_id) + || document.plugin_id != plugin_id + || document.plugin_version != plugin_version + || signature_bytes.len() != 64 + { + return Err(TrustError::SignatureInvalid( + "document fields do not match the package".to_string(), + )); + } + let actual = file_digests(package_root)?; + if actual.keys().len() != document.files.len() + || actual + .keys() + .zip(document.files.keys()) + .any(|(left, right)| left != right) + { + return Err(TrustError::SignatureInvalid( + "the package file list does not match its signature".to_string(), + )); + } + for (path, digest) in &actual { + if document.files.get(path).map(String::as_str) != Some(digest.as_str()) { + return Err(TrustError::SignatureInvalid(format!( + "the package file was changed after signing: {path}" + ))); + } + } + let payload = canonical_payload( + &document.key_id, + &document.plugin_id, + &document.plugin_version, + &document.files, + ); + let signature = Signature::from_slice(&signature_bytes) + .map_err(|_| TrustError::SignatureInvalid("signature is not 64 bytes".to_string()))?; + public_key + .verify(&payload, &signature) + .map_err(|_| TrustError::SignatureInvalid("signature does not verify".to_string())) +} + +/// SHA-256 digest of every package file, keyed by relative path with `/` +/// separators in ascending order. Refuses reparse points, symlinks, unsafe +/// names, and non-UTF-8 names; the signature document itself is skipped. +pub fn file_digests(package_root: &Path) -> Result, TrustError> { + if !package_root.is_dir() { + return Err(TrustError::SignatureInvalid( + "the package root is not a directory".to_string(), + )); + } + let mut digests = BTreeMap::new(); + walk_files(package_root, "", &mut digests)?; + Ok(digests) +} + +fn walk_files( + directory: &Path, + prefix: &str, + digests: &mut BTreeMap, +) -> Result<(), TrustError> { + let entries = std::fs::read_dir(directory).map_err(|error| { + TrustError::SignatureInvalid(format!("unreadable package entry: {error}")) + })?; + for entry in entries { + let entry = entry + .map_err(|error| TrustError::SignatureInvalid(format!("unreadable entry: {error}")))?; + let path = entry.path(); + let name = entry.file_name(); + // Windows package names are compared as UTF-8 text; names with + // invalid UTF-16 surrogates cannot receive a signature token. + let name = name.to_str().ok_or_else(|| { + TrustError::SignatureInvalid("a package entry name is not UTF-8".to_string()) + })?; + let metadata = std::fs::symlink_metadata(&path).map_err(|error| { + TrustError::SignatureInvalid(format!("{} is unreadable: {error}", path.display())) + })?; + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + // Reparse-point attribute: covers symbolic links and junctions. + if metadata.file_attributes() & 0x0400 != 0 { + return Err(TrustError::SignatureInvalid(format!( + "{} is a reparse point; package entries must be regular files", + path.display() + ))); + } + } + #[cfg(not(windows))] + if metadata.file_type().is_symlink() { + return Err(TrustError::SignatureInvalid(format!( + "{} is a symbolic link; package entries must be regular files", + path.display() + ))); + } + if metadata.is_dir() { + let prefix = if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}/{name}") + }; + walk_files(&path, &prefix, digests)?; + continue; + } + if !metadata.is_file() { + return Err(TrustError::SignatureInvalid(format!( + "{} is not a regular file", + path.display() + ))); + } + let relative = if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}/{name}") + }; + if relative.contains('\0') || relative.contains('\n') || relative.contains('\r') { + return Err(TrustError::SignatureInvalid(format!( + "the package contains an unsupported file: {relative}" + ))); + } + if relative.split('/').any(|part| part == "..") { + return Err(TrustError::SignatureInvalid(format!( + "the package contains an unsupported file: {relative}" + ))); + } + // Only the root-level signature document is excluded; a nested file + // with the same name is an ordinary package file. + if relative == SIGNATURE_FILE_NAME { + continue; + } + let bytes = std::fs::read(&path).map_err(|error| { + TrustError::SignatureInvalid(format!("{} is unreadable: {error}", path.display())) + })?; + digests.insert(relative, hex(&Sha256::digest(&bytes))); + } + Ok(()) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::STANDARD; + use ed25519_dalek::{Signer, SigningKey}; + + /// The RFC 8032 Ed25519 test key, used as the shared public test seed in + /// `signature-vectors.json`; it must never sign real packages. + const RFC8032_SEED_HEX: &str = + "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"; + const RFC8032_PUBLIC_HEX: &str = + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; + /// RFC 8032 TEST 1: signature over the empty message. + const RFC8032_EMPTY_SIGNATURE_HEX: &str = "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"; + + fn test_signing_key() -> SigningKey { + rfc8032_signing_key_for_tests() + } + + fn hex_to_bytes(hex: &str) -> Vec { + (0..hex.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&hex[index..index + 2], 16).expect("hex byte")) + .collect() + } + + /// The primitive is anchored to the public RFC 8032 vector, not to this + /// crate's own round-trip, so both platforms verify the same known answer. + #[test] + fn ed25519_matches_the_rfc_8032_test_vector() { + let key = test_signing_key(); + assert_eq!(hex(&key.verifying_key().to_bytes()), RFC8032_PUBLIC_HEX); + let signature = key.sign(b""); + assert_eq!(hex(&signature.to_bytes()), RFC8032_EMPTY_SIGNATURE_HEX); + assert!(key.verifying_key().verify(b"", &signature).is_ok()); + } + + /// Prints the shared fixture values; run with `--ignored --nocapture` + /// when the fixture must be regenerated, then paste into + /// shared/contracts/plugins/v1/fixtures/signature-vectors.json. The + /// committed fixture was produced from the same construction (see the + /// fixture's description); the end-to-end test below re-derives it. + #[test] + #[ignore = "fixture generator; output is committed as signature-vectors.json"] + fn generate_signature_vectors_fixture() { + let key = test_signing_key(); + let files_content = [ + ("plugin.json", "plugin manifest bytes"), + ("language-server/bin/server.js", "console.log('server');\n"), + ("插件/服务器.js", "console.log('非 ASCII 路径');\n"), + ("日本語/README.md", "# サンプル\n"), + ("assets/заметки.txt", "заметки\n"), + ]; + let files: BTreeMap = files_content + .iter() + .map(|(path, content)| (path.to_string(), hex(&Sha256::digest(content.as_bytes())))) + .collect(); + let payload = canonical_payload("lithe-test-v1", "dev.example.plugin", "0.3.0", &files); + let signature = key.sign(&payload); + let digest = { + let lines: String = files + .iter() + .map(|(path, digest)| format!("{}={digest}\n", token(path))) + .collect(); + hex(&Sha256::digest(lines.as_bytes())) + }; + println!( + "{}", + serde_json::json!({ + "ed25519": { + "seedHex": RFC8032_SEED_HEX, + "publicKeyHex": RFC8032_PUBLIC_HEX, + "messageEmptySignatureHex": RFC8032_EMPTY_SIGNATURE_HEX, + }, + "packageFiles": files_content + .iter() + .map(|(path, content)| (path.to_string(), content.to_string())) + .collect::>(), + "canonicalPayload": { + "keyID": "lithe-test-v1", + "pluginID": "dev.example.plugin", + "pluginVersion": "0.3.0", + "files": files, + "payloadBase64": STANDARD.encode(&payload), + "signatureBase64": STANDARD.encode(signature.to_bytes()), + }, + "contentDigest": { + "files": files, + "digest": digest, + }, + }) + ); + } + + #[test] + fn token_encoding_matches_the_macos_base64url_rules() { + assert_eq!(token("plugin.json"), "cGx1Z2luLmpzb24"); + // `+` and `/` map to `-` and `_`; padding is omitted. + assert_eq!(token("~?"), "fj8"); + // Non-ASCII paths are base64url of their UTF-8 bytes: the round trip + // reproduces the input and the output uses the URL-safe alphabet. + let path = "插件/服务器.js"; + let token = token(path); + assert!(!token.contains('+') && !token.contains('/') && !token.contains('=')); + let decoded = BASE64URL_NO_PAD + .decode(token.as_bytes()) + .expect("token decodes"); + assert_eq!(String::from_utf8(decoded).expect("UTF-8"), path); + } + + /// A `sameTeamAsHost` package is only honored for a bundled origin whose + /// root is provably inside the trusted resource root; a record's claim + /// alone, or a root outside the installation, is a refused seal. + #[test] + fn bundled_origin_requires_a_verified_bundled_root() { + let manifest = serde_json::json!({ + "vendor": { "signatureRequirement": "sameTeamAsHost" } + }); + let root = std::env::temp_dir().join(format!( + "lithe-sig-seal-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + let package_root = root.join("OfficialPlugins").join("pkg"); + std::fs::create_dir_all(&package_root).expect("package dir"); + let outside_root = + std::env::temp_dir().join(format!("lithe-sig-seal-outside-{}", std::process::id())); + let keys = trusted_keys(); + + // No trusted root at all: a launch path that cannot prove the seal + // refuses instead of guessing. + assert_eq!( + verify_for_install( + &package_root, + "dev.lithe.plugin.php-support", + "1.0.0", + &manifest, + InstallOrigin::Bundled, + None, + &keys + ), + Err(TrustError::HostSealRequired) + ); + // A trusted root that does not contain the package. + assert_eq!( + verify_for_install( + &package_root, + "dev.lithe.plugin.php-support", + "1.0.0", + &manifest, + InstallOrigin::Bundled, + Some(&outside_root), + &keys + ), + Err(TrustError::HostSealRequired) + ); + // The real containment proof. + assert_eq!( + verify_for_install( + &package_root, + "dev.lithe.plugin.php-support", + "1.0.0", + &manifest, + InstallOrigin::Bundled, + Some(&root.join("OfficialPlugins")), + &keys + ), + Ok(()) + ); + // The same package on disk can never claim the seal, even though the + // root would contain it, because the origin is not bundled. + assert_eq!( + verify_for_install( + &package_root, + "dev.lithe.plugin.php-support", + "1.0.0", + &manifest, + InstallOrigin::Community, + Some(&root.join("OfficialPlugins")), + &keys + ), + Err(TrustError::HostSealRequired) + ); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&outside_root); + } + + #[test] + fn unsafe_package_entries_are_refused() { + let root = std::env::temp_dir().join(format!("lithe-sig-unsafe-{}", std::process::id())); + let nested = root.join("sub"); + std::fs::create_dir_all(&nested).expect("dir"); + std::fs::write(nested.join("server.js"), b"ok").expect("file"); + let digests = file_digests(&root).expect("digests"); + assert!(digests.contains_key("sub/server.js")); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn official_ids_keep_their_pinned_signature_requirement() { + assert_eq!( + official_signature_requirement("dev.lithe.plugin.php-support"), + Some(SignatureRequirement::SameTeamAsHost) + ); + assert_eq!( + official_signature_requirement("dev.lithe.plugin.go-support"), + Some(SignatureRequirement::PublisherPackage) + ); + assert_eq!(official_signature_requirement("io.example.plugin"), None); + let manifest = serde_json::json!({ + "vendor": { "signatureRequirement": "none" } + }); + assert_eq!( + declared_signature_requirement(&manifest), + Ok(SignatureRequirement::Unsigned) + ); + assert_eq!( + declared_signature_requirement(&serde_json::json!({ "vendor": {} })), + Err(TrustError::InvalidPolicy) + ); + } + + /// Regenerating the fixture changes the expected digest; the test then + /// forces a deliberate look at the fixture instead of drifting silently. + #[test] + fn shared_fixture_verifies_end_to_end() { + let fixture_path = std::path::PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/contracts/plugins/v1/fixtures/signature-vectors.json" + )); + let fixture: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(&fixture_path).expect("signature-vectors.json is readable"), + ) + .expect("signature-vectors.json is JSON"); + + // The primitive anchor in the fixture matches the RFC vector above. + assert_eq!(fixture["ed25519"]["seedHex"], RFC8032_SEED_HEX); + assert_eq!(fixture["ed25519"]["publicKeyHex"], RFC8032_PUBLIC_HEX); + assert_eq!( + fixture["ed25519"]["messageEmptySignatureHex"], + RFC8032_EMPTY_SIGNATURE_HEX + ); + + // The recorded package signature verifies with the shared public key + // and the recorded canonical payload reproduces byte for byte. + let payload_case = &fixture["canonicalPayload"]; + let mut files = BTreeMap::new(); + for (path, digest) in payload_case["files"].as_object().expect("files object") { + files.insert( + path.clone(), + digest.as_str().expect("digest string").to_string(), + ); + } + let payload = canonical_payload( + payload_case["keyID"].as_str().expect("keyID"), + payload_case["pluginID"].as_str().expect("pluginID"), + payload_case["pluginVersion"] + .as_str() + .expect("pluginVersion"), + &files, + ); + assert_eq!( + STANDARD.encode(&payload), + payload_case["payloadBase64"] + .as_str() + .expect("payloadBase64"), + "canonical payload bytes diverged from the shared fixture" + ); + let key = test_signing_key(); + let signature = key.sign(&payload); + assert_eq!( + STANDARD.encode(signature.to_bytes()), + payload_case["signatureBase64"] + .as_str() + .expect("signatureBase64"), + "deterministic Ed25519 must reproduce the shared signature" + ); + + // The materialized package tree verifies through the real verify path. + let root = std::env::temp_dir().join(format!( + "lithe-sig-vector-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + for (path, content) in fixture["packageFiles"].as_object().expect("packageFiles") { + let target = root.join(path); + std::fs::create_dir_all(target.parent().expect("parent")).expect("package dir"); + std::fs::write(&target, content.as_str().expect("content")).expect("package file"); + } + let document = SignatureDocument { + schema_version: SCHEMA_VERSION, + algorithm: ALGORITHM.to_string(), + key_id: payload_case["keyID"].as_str().expect("keyID").to_string(), + plugin_id: payload_case["pluginID"] + .as_str() + .expect("pluginID") + .to_string(), + plugin_version: payload_case["pluginVersion"] + .as_str() + .expect("pluginVersion") + .to_string(), + files, + signature: payload_case["signatureBase64"] + .as_str() + .expect("signatureBase64") + .to_string(), + }; + verify_document( + &root, + &document.plugin_id, + &document.plugin_version, + &document, + &{ + let mut keys = BTreeMap::new(); + keys.insert("lithe-test-v1".to_string(), key.verifying_key()); + keys + }, + ) + .expect("fixture package verifies"); + + // Tampering with any file is rejected. + std::fs::write(root.join("plugin.json"), b"changed bytes").expect("tamper"); + assert!(matches!( + verify_document( + &root, + &document.plugin_id, + &document.plugin_version, + &document, + &{ + let mut keys = BTreeMap::new(); + keys.insert("lithe-test-v1".to_string(), key.verifying_key()); + keys + } + ), + Err(TrustError::SignatureInvalid(_)) + )); + let _ = std::fs::remove_dir_all(&root); + + // The recorded content digest matches the un-tampered tree. + let digest_case = &fixture["contentDigest"]; + let mut digest_files = BTreeMap::new(); + for (path, digest) in digest_case["files"].as_object().expect("digest files") { + digest_files.insert( + path.clone(), + digest.as_str().expect("digest string").to_string(), + ); + } + let expected_lines: String = digest_files + .iter() + .map(|(path, digest)| format!("{}={digest}\n", token(path))) + .collect(); + assert_eq!( + hex(&Sha256::digest(expected_lines.as_bytes())), + digest_case["digest"].as_str().expect("digest"), + "content digest diverged from the shared fixture" + ); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/store.rs b/windows/tauri/src-tauri/src/plugin_host/store.rs index 290ab9075e..6bb9d0fc79 100644 --- a/windows/tauri/src-tauri/src/plugin_host/store.rs +++ b/windows/tauri/src-tauri/src/plugin_host/store.rs @@ -55,7 +55,12 @@ pub fn workspace_state_key(workspace_path: &str) -> String { format!("{hash:016x}") } -/// Persistent install record of one installed plugin. +/// Persistent install record of one installed plugin (schema 2). The record +/// is the only trust evidence for installed packages: which origin the +/// package came from, which byte digest the user approved, and which +/// programs were approved. Schema 1 records carry no provenance; the scan +/// quarantines them instead of reconstructing trust from `enabled` or the +/// manifest. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InstallationRecord { @@ -63,20 +68,98 @@ pub struct InstallationRecord { pub plugin_id: String, /// Active version directory name under the plugin directory. pub version: String, + /// Version the user can roll back to; verified with its own provenance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_version: Option, #[serde(default = "default_enabled")] pub enabled: bool, + /// Where the active version came from. `None` marks a schema 1 record + /// written before provenance existed; such records never load. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin: Option, + /// Origin of `previousVersion`; rollback verifies the restored package + /// by how it was installed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_origin: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub marketplace: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_marketplace: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disk: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_disk: Option, +} + +impl InstallationRecord { + /// Provenance of the active version: exactly one source owns an approval, + /// so marketplace and disk provenance are mutually exclusive. + pub fn active_content_digest(&self) -> Option<&str> { + match (&self.marketplace, &self.disk) { + (Some(_), Some(_)) | (None, None) => None, + (Some(marketplace), None) => Some(&marketplace.content_digest), + (None, Some(disk)) => Some(&disk.content_digest), + } + } + + /// Content digest recorded for `previousVersion`, for rollback checks. + pub fn previous_content_digest(&self) -> Option<&str> { + match (&self.previous_marketplace, &self.previous_disk) { + (Some(_), Some(_)) | (None, None) => None, + (Some(marketplace), None) => Some(&marketplace.content_digest), + (None, Some(disk)) => Some(&disk.content_digest), + } + } } fn default_enabled() -> bool { true } +/// A user-selected local package, without any claim of repository +/// verification. The content digest binds the approval to the installed +/// files. Mirrors the macOS `PluginDiskProvenance`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiskProvenance { + pub file_name: String, + pub archive_sha256: Option, + pub content_digest: String, + /// Canonical JSON of every approved program from the install review. + pub approved_programs: Vec, +} + +/// Where a marketplace installation came from and what the user approved. +/// Reserved for the marketplace milestone (M2), which writes this record; +/// the scan already rechecks its digest for community origin. Mirrors the +/// macOS `PluginMarketplaceProvenance`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MarketplaceProvenance { + pub source_id: String, + pub source_name: String, + pub repository: String, + #[serde(default)] + pub index_url: String, + #[serde(default)] + pub archive_url: String, + pub archive_sha256: String, + pub content_digest: String, + pub approved_programs: Vec, +} + /// One package directory candidate found on disk. pub struct DiscoveredPackage { /// Directory that directly contains `plugin.json`. pub root: PathBuf, pub source: &'static str, pub enabled: bool, + /// Trust origin recorded for this package. Bundled packages carry + /// `Bundled`; `None` marks a schema 1 record without provenance, which + /// the scan quarantines. + pub origin: Option, + /// Provenance of the active version (digest of the approved bytes). + pub content_digest: Option, /// Set when the package was found but its install record is missing or /// invalid; scan quarantines the package with this reason. pub record_problem: Option, @@ -106,6 +189,8 @@ pub fn discover_installed_in(install_root: &Path) -> Result Result 2 { packages.push(quarantined(format!( "unsupported installation record schema {}", record.schema_version ))); continue; } + if record.schema_version == 1 { + // A schema 1 record predates provenance: the enabled flag and the + // manifest say nothing about which bytes were approved, so the + // package is quarantined until the user reinstalls it. + packages.push(quarantined( + "the install record predates content provenance; reinstall the plugin to \ + approve it with the current trust rules" + .to_string(), + )); + continue; + } + if record.plugin_id + != plugin_directory + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default() + { + packages.push(quarantined( + "the install record does not match its plugin directory".to_string(), + )); + continue; + } + // Bundled identity comes only from the trusted resource discoverer: + // an installed record claiming it (for the active or the previous + // version) is forged and the package is quarantined. + if record.origin == Some(super::signature::InstallOrigin::Bundled) + || record.previous_origin == Some(super::signature::InstallOrigin::Bundled) + { + packages.push(quarantined( + "the install record claims bundled provenance; only packages discovered inside \ + the host installation are bundled" + .to_string(), + )); + continue; + } + // Community and marketplace packages carry the digest of the bytes + // their approval covers; without it nothing can prove those bytes. + if record.active_content_digest().is_none() + && matches!( + record.origin, + Some( + super::signature::InstallOrigin::Community + | super::signature::InstallOrigin::Marketplace + ) + ) + { + packages.push(quarantined( + super::signature::TrustError::ProvenanceMissing.message(), + )); + continue; + } let version_directory = plugin_directory.join(&record.version); if !version_directory.join("plugin.json").is_file() { packages.push(quarantined(format!( @@ -144,6 +280,8 @@ pub fn discover_installed_in(install_root: &Path) -> Result Result continue; } if package_directory.join("plugin.json").is_file() { - packages.push(DiscoveredPackage { - root: package_directory, - source: "bundled", - enabled: true, - record_problem: None, - }); + packages.push(bundled_package(package_directory)); continue; } let mut versions: Vec<(String, PathBuf)> = Vec::new(); @@ -200,16 +333,25 @@ pub fn discover_bundled_in(bundled_root: &Path) -> Result else { continue; }; - packages.push(DiscoveredPackage { - root: best, - source: "bundled", - enabled: true, - record_problem: None, - }); + packages.push(bundled_package(best)); } Ok(packages) } +/// A bundled package discovered inside the host installation. Bundled +/// packages carry no install record: they ship with the app and are trusted +/// through their location, subject to the pinned signature policy. +fn bundled_package(root: PathBuf) -> DiscoveredPackage { + DiscoveredPackage { + root, + source: "bundled", + enabled: true, + origin: Some(super::signature::InstallOrigin::Bundled), + content_digest: None, + record_problem: None, + } +} + /// Dot-separated version comparison with numeric-aware chunks, so `1.10` /// sorts after `1.9` without pulling in a semver dependency. pub fn compare_versions(left: &str, right: &str) -> Ordering { diff --git a/windows/tauri/src-tauri/src/plugin_host/tests.rs b/windows/tauri/src-tauri/src/plugin_host/tests.rs index 2d5aee99c4..df59561827 100644 --- a/windows/tauri/src-tauri/src/plugin_host/tests.rs +++ b/windows/tauri/src-tauri/src/plugin_host/tests.rs @@ -163,14 +163,27 @@ fn scan_accepts_valid_packages_and_quarantines_broken_ones() { let root = unique_temp_root("scan"); let package_root = root.join("acme.php"); std::fs::create_dir_all(&package_root).expect("package dir"); - let manifest = read_fixture("language-packaged-server.plugin.json"); + // A community install requires a non-official ID and an unsigned trust + // declaration; the fixture's official-namespace manifest is reshaped the + // way a real disk package looks. + let mut manifest = read_fixture("language-packaged-server.plugin.json"); + manifest["id"] = serde_json::json!("io.example.acme-php"); + manifest["vendor"]["signatureRequirement"] = serde_json::json!("none"); std::fs::write( package_root.join("plugin.json"), serde_json::to_string_pretty(&manifest).expect("manifest text"), ) .expect("manifest file"); - let accepted = scan_package_at(&package_root, None); + let accepted = scan_package_at( + &package_root, + None, + Some(crate::plugin_host::signature::InstallOrigin::Community), + Some( + crate::plugin_host::signature::content_digest(&package_root) + .expect("digest of the package"), + ), + ); assert_eq!(accepted.entry.status, "accepted"); assert_eq!(accepted.entry.plugin_id, manifest["id"]); assert_eq!(accepted.entry.languages.len(), 1); @@ -180,7 +193,7 @@ fn scan_accepts_valid_packages_and_quarantines_broken_ones() { ); std::fs::write(package_root.join("plugin.json"), "{ not json").expect("broken manifest"); - let broken = scan_package_at(&package_root, None); + let broken = scan_package_at(&package_root, None, None, None); assert_eq!(broken.entry.status, "rejected"); assert_eq!( broken.entry.rejection.as_ref().expect("rejection").code, @@ -213,19 +226,46 @@ fn store_problems_quarantine_packages_with_valid_manifests() { // A record naming a missing version is a store problem too. std::fs::write( plugin_directory.join("installation.json"), - json!({"schemaVersion": 1, "pluginId": "acme.php", "version": "9.9.9"}).to_string(), + json!({"schemaVersion": 2, "pluginId": "acme.php", "version": "9.9.9", "origin": "community"}).to_string(), ) .expect("record file"); let discovered = store::discover_installed_in(&root).expect("discovery works"); assert!(discovered[0].record_problem.is_some()); - // Correct record: discovered at the recorded version, enabled by default. + // A schema 1 record predates content provenance: it is quarantined even + // though it names the installed version, because the enabled flag and + // the manifest cannot prove which bytes the user approved. std::fs::write( plugin_directory.join("installation.json"), json!({"schemaVersion": 1, "pluginId": "acme.php", "version": "1.0.0"}).to_string(), ) .expect("record file"); let discovered = store::discover_installed_in(&root).expect("discovery works"); + assert!(discovered[0].record_problem.is_some()); + assert!(discovered[0] + .record_problem + .as_deref() + .unwrap_or_default() + .contains("provenance")); + + // A schema 2 community record without a content digest is the same trap. + std::fs::write( + plugin_directory.join("installation.json"), + json!({"schemaVersion": 2, "pluginId": "acme.php", "version": "1.0.0", "origin": "community"}).to_string(), + ) + .expect("record file"); + let discovered = store::discover_installed_in(&root).expect("discovery works"); + assert!(discovered[0].record_problem.is_some()); + + // Correct record: discovered at the recorded version, enabled by default. + write_record_v2( + &plugin_directory, + "acme.php", + "1.0.0", + &version_directory, + true, + ); + let discovered = store::discover_installed_in(&root).expect("discovery works"); assert!(discovered[0].record_problem.is_none()); assert_eq!(discovered[0].root, version_directory); assert!(discovered[0].enabled); @@ -261,24 +301,30 @@ fn catalog_build_combines_bundled_and_installed_sources() { let bundled_root = temporary.join("OfficialPlugins"); let installed_version = install_root.join("acme.php").join("1.0.0"); std::fs::create_dir_all(&installed_version).expect("installed dir"); + let mut installed_manifest = read_fixture("language-packaged-server.plugin.json"); + installed_manifest["id"] = serde_json::json!("io.example.acme-php"); + installed_manifest["vendor"]["signatureRequirement"] = serde_json::json!("none"); std::fs::write( installed_version.join("plugin.json"), - serde_json::to_string(&read_fixture("language-packaged-server.plugin.json")) - .expect("manifest"), + serde_json::to_string_pretty(&installed_manifest).expect("manifest"), ) .expect("manifest file"); - std::fs::write( - install_root.join("acme.php").join("installation.json"), - json!({"schemaVersion": 1, "pluginId": "acme.php", "version": "1.0.0", "enabled": false}) - .to_string(), - ) - .expect("record file"); + write_record_v2( + &install_root.join("acme.php"), + "acme.php", + "1.0.0", + &installed_version, + false, + ); + // A bundled package is trusted through its location: its manifest may + // keep the sameTeamAsHost policy the bundled PHP contract package uses. + let mut bundled_manifest = read_fixture("language-system-command.plugin.json"); + bundled_manifest["vendor"]["signatureRequirement"] = serde_json::json!("sameTeamAsHost"); let bundled_directory = bundled_root.join("acme.go").join("2.0.0"); std::fs::create_dir_all(&bundled_directory).expect("bundled dir"); std::fs::write( bundled_directory.join("plugin.json"), - serde_json::to_string(&read_fixture("language-system-command.plugin.json")) - .expect("manifest"), + serde_json::to_string_pretty(&bundled_manifest).expect("manifest"), ) .expect("manifest file"); @@ -316,17 +362,56 @@ fn unique_temp_root(label: &str) -> PathBuf { root } -/// Scans one package directory through the production scan path. +/// Scans one package directory through the production scan path. `origin` +/// and `content_digest` carry the trust evidence an installation record +/// would provide; `None` mirrors a record without provenance. fn scan_package_at( root: &std::path::Path, record_problem: Option, + origin: Option, + content_digest: Option, ) -> super::ScannedPackage { - super::scan::scan_package_for_tests(DiscoveredPackage { - root: root.to_path_buf(), - source: "installed", - enabled: true, - record_problem, - }) + super::scan::scan_package_for_tests( + DiscoveredPackage { + root: root.to_path_buf(), + source: "installed", + enabled: true, + origin, + content_digest, + record_problem, + }, + None, + ) +} + +/// Writes a schema 2 community record naming `version`, with the content +/// digest of `version_directory` as the approved-bytes evidence. +fn write_record_v2( + plugin_directory: &std::path::Path, + plugin_id: &str, + version: &str, + version_directory: &std::path::Path, + enabled: bool, +) { + let record = json!({ + "schemaVersion": 2, + "pluginId": plugin_id, + "version": version, + "enabled": enabled, + "origin": "community", + "disk": { + "fileName": "plugin.zip", + "archiveSHA256": null, + "contentDigest": crate::plugin_host::signature::content_digest(version_directory) + .expect("digest of the package directory"), + "approvedPrograms": [], + } + }); + std::fs::write( + plugin_directory.join("installation.json"), + record.to_string(), + ) + .expect("record file"); } /// Fixture reader shared with the install test module. @@ -334,6 +419,289 @@ pub(crate) fn read_fixture_for_tests(name: &str) -> serde_json::Value { read_fixture(name) } +/// Writes a community-installable package (non-official ID, unsigned trust +/// declaration) and its schema 2 record into an install root. +fn install_community_package(install_root: &std::path::Path, version: &str) -> std::path::PathBuf { + let plugin_directory = install_root.join("io.example.acme-php"); + let version_directory = plugin_directory.join(version); + std::fs::create_dir_all(&version_directory).expect("version dir"); + let mut manifest = read_fixture("language-packaged-server.plugin.json"); + manifest["id"] = serde_json::json!("io.example.acme-php"); + manifest["vendor"]["signatureRequirement"] = serde_json::json!("none"); + manifest["version"] = serde_json::json!(version); + std::fs::write( + version_directory.join("plugin.json"), + serde_json::to_string_pretty(&manifest).expect("manifest text"), + ) + .expect("manifest file"); + std::fs::write(version_directory.join("server.js"), b"entry").expect("package file"); + write_record_v2( + &plugin_directory, + "io.example.acme-php", + version, + &version_directory, + true, + ); + version_directory +} + +/// The real bundled PHP contract package passes the M1 trust gate exactly +/// the way the app's resource-directory scan will treat it: bundled origin, +/// pinned `sameTeamAsHost` policy, no install record. +#[test] +fn real_bundled_contract_package_passes_the_trust_gate() { + let source = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../Plugins/win/Official/PhpSupportContract" + ); + let root = unique_temp_root("trust-bundled"); + let bundled_root = root + .join("OfficialPlugins") + .join("dev.lithe.plugin.php-support"); + std::fs::create_dir_all(&bundled_root).expect("bundled root"); + copy_directory_snapshot_for_tests(std::path::Path::new(source), &bundled_root); + + let catalog = build_catalog_from(&root.join("OfficialPlugins"), &root.join("none")) + .expect("catalog builds"); + let package = catalog + .iter() + .find(|package| package.entry.source == "bundled") + .expect("bundled entry"); + let entry = &package.entry; + assert_eq!(entry.status, "accepted", "{:?}", entry.rejection); + assert_eq!(entry.plugin_id, "dev.lithe.plugin.php-support"); + assert!(entry.enabled); + // The trust evidence recorded for launch re-verification is bundled. + assert!(matches!( + package.trust.as_ref().map(|trust| trust.origin), + Some(crate::plugin_host::signature::InstallOrigin::Bundled) + )); + let _ = std::fs::remove_dir_all(&root); +} + +/// Copies a package tree without following reparse points, mirroring the +/// snapshot copy the installer performs. +fn copy_directory_snapshot_for_tests(source: &std::path::Path, target: &std::path::Path) { + std::fs::create_dir_all(target).expect("target dir"); + for entry in std::fs::read_dir(source).expect("source readable") { + let entry = entry.expect("entry"); + let entry_target = target.join(entry.file_name()); + if entry.path().is_dir() { + copy_directory_snapshot_for_tests(&entry.path(), &entry_target); + } else { + std::fs::copy(entry.path(), &entry_target).expect("file copy"); + } + } +} + +/// P1 regression: an installed record can never claim bundled origin. The +/// bundled identity comes only from the trusted resource discoverer; a +/// schema 2 record that claims it (for the active or the previous version) +/// is forged and the package is quarantined instead of scanned. +#[test] +fn installed_records_cannot_claim_bundled_origin() { + let root = unique_temp_root("trust-bundled-claim"); + let install_root = root.join("plugins"); + let plugin_directory = install_root.join("dev.lithe.plugin.php-support"); + let version_directory = plugin_directory.join("1.0.0"); + std::fs::create_dir_all(&version_directory).expect("version dir"); + let mut manifest = read_fixture("language-packaged-server.plugin.json"); + manifest["id"] = serde_json::json!("dev.lithe.plugin.php-support"); + manifest["vendor"]["signatureRequirement"] = serde_json::json!("sameTeamAsHost"); + manifest["version"] = serde_json::json!("1.0.0"); + std::fs::write( + version_directory.join("plugin.json"), + serde_json::to_string_pretty(&manifest).expect("manifest text"), + ) + .expect("manifest file"); + std::fs::write(version_directory.join("server.js"), b"entry").expect("package file"); + + // The forged record claims bundled origin with no digest at all. + std::fs::write( + plugin_directory.join("installation.json"), + json!({ + "schemaVersion": 2, + "pluginId": "dev.lithe.plugin.php-support", + "version": "1.0.0", + "enabled": true, + "origin": "bundled" + }) + .to_string(), + ) + .expect("record file"); + let discovered = store::discover_installed_in(&install_root).expect("discovery works"); + assert!( + discovered[0].record_problem.is_some(), + "a bundled-origin claim must be a record problem" + ); + let catalog = build_catalog_from(&root.join("none"), &install_root).expect("catalog builds"); + let entry = &catalog + .iter() + .find(|package| package.entry.source == "installed") + .expect("installed entry") + .entry; + assert_eq!(entry.status, "rejected", "the forged package must not load"); + + // The same holds when only the previous version claims bundled origin. + let digest = crate::plugin_host::signature::content_digest(&version_directory) + .expect("digest of the package"); + std::fs::write( + plugin_directory.join("installation.json"), + json!({ + "schemaVersion": 2, + "pluginId": "dev.lithe.plugin.php-support", + "version": "1.0.0", + "enabled": true, + "origin": "community", + "disk": { + "fileName": "plugin.zip", + "archiveSHA256": null, + "contentDigest": digest, + "approvedPrograms": [] + }, + "previousVersion": "0.9.0", + "previousOrigin": "bundled" + }) + .to_string(), + ) + .expect("record file"); + let discovered = store::discover_installed_in(&install_root).expect("discovery works"); + assert!( + discovered[0].record_problem.is_some(), + "a bundled previous-origin claim must be a record problem too" + ); + let _ = std::fs::remove_dir_all(&root); +} + +/// The bytes of an installed community package are rechecked against the +/// recorded content digest on every scan, so a package tampered while the +/// app was not running is quarantined at the next startup instead of loaded. +#[test] +fn scan_quarantines_a_tampered_installed_package() { + let root = unique_temp_root("trust-tamper"); + let install_root = root.join("plugins"); + install_community_package(&install_root, "1.0.0"); + + let catalog = build_catalog_from(&root.join("none"), &install_root).expect("catalog builds"); + let entry = &catalog + .iter() + .find(|package| package.entry.source == "installed") + .expect("installed entry") + .entry; + assert_eq!(entry.status, "accepted"); + + // Tamper while the app is not running: the next scan must refuse. + std::fs::write( + install_root + .join("io.example.acme-php") + .join("1.0.0") + .join("server.js"), + b"tampered", + ) + .expect("tamper"); + let catalog = build_catalog_from(&root.join("none"), &install_root).expect("catalog builds"); + let entry = &catalog + .iter() + .find(|package| package.entry.source == "installed") + .expect("installed entry") + .entry; + assert_eq!(entry.status, "rejected"); + let rejection = entry.rejection.as_ref().expect("rejection"); + assert_eq!(rejection.code, "plugin.trust.contentChanged"); + + // An added file is the same rejection: the approved file set is exact. + std::fs::write( + install_root + .join("io.example.acme-php") + .join("1.0.0") + .join("server.js"), + b"entry", + ) + .expect("restore"); + std::fs::write( + install_root + .join("io.example.acme-php") + .join("1.0.0") + .join("extra.js"), + b"added", + ) + .expect("add file"); + let catalog = build_catalog_from(&root.join("none"), &install_root).expect("catalog builds"); + let entry = &catalog + .iter() + .find(|package| package.entry.source == "installed") + .expect("installed entry") + .entry; + assert_eq!( + entry.rejection.as_ref().expect("rejection").code, + "plugin.trust.contentChanged" + ); + let _ = std::fs::remove_dir_all(&root); +} + +/// The host's official-ID trust policy overrides the package declaration: a +/// pinned official ID cannot install as an unsigned community package even +/// when its manifest claims a signature requirement the host could honor. +#[test] +fn official_ids_reject_unsigned_community_installs() { + let root = unique_temp_root("trust-official"); + let package_root = root.join("pkg"); + std::fs::create_dir_all(&package_root).expect("package dir"); + let mut manifest = read_fixture("language-packaged-server.plugin.json"); + // The pinned PHP support ID; its bundled contract package legitimately + // uses sameTeamAsHost, which a disk install can never prove. + manifest["id"] = serde_json::json!("dev.lithe.plugin.php-support"); + manifest["vendor"]["signatureRequirement"] = serde_json::json!("sameTeamAsHost"); + std::fs::write( + package_root.join("plugin.json"), + serde_json::to_string_pretty(&manifest).expect("manifest text"), + ) + .expect("manifest file"); + std::fs::write(package_root.join("server.js"), b"entry").expect("package file"); + + let scanned = scan_package_at( + &package_root, + None, + Some(crate::plugin_host::signature::InstallOrigin::Community), + None, + ); + assert_eq!(scanned.entry.status, "rejected"); + let rejection = scanned.entry.rejection.as_ref().expect("rejection"); + // The pinned sameTeamAsHost policy can never be honored from disk: the + // package is refused with the seal error even though it declares a + // requirement the host pins. + assert_eq!( + rejection.code, "plugin.trust.hostSealRequired", + "{rejection:?}" + ); + let _ = std::fs::remove_dir_all(&root); +} + +/// A signature document present in a community package must verify: a forged +/// or damaged document is never silently ignored. +#[test] +fn a_present_signature_document_must_verify_even_for_community_packages() { + let root = unique_temp_root("trust-signature"); + let install_root = root.join("plugins"); + let version_directory = install_community_package(&install_root, "1.0.0"); + std::fs::write( + version_directory.join("lithe-plugin-signature.json"), + r#"{"schemaVersion":1,"algorithm":"ed25519","keyID":"lithe-test-v1","pluginID":"io.example.acme-php","pluginVersion":"1.0.0","files":{},"signature":"AAAA"}"#, + ) + .expect("signature file"); + let catalog = build_catalog_from(&root.join("none"), &install_root).expect("catalog builds"); + let entry = &catalog + .iter() + .find(|package| package.entry.source == "installed") + .expect("installed entry") + .entry; + assert_eq!( + entry.rejection.as_ref().expect("rejection").code, + "plugin.trust.signatureInvalid" + ); + let _ = std::fs::remove_dir_all(&root); +} + /// M3 setting-store tests exercise the two persistence layers directly: the /// global per-plugin file and the workspace's `.lithe/plugin-settings.json`. mod settings_tests { @@ -529,6 +897,8 @@ fn concurrent_catalog_refreshes_publish_in_discovery_order() { Ok(vec![scan_package_at( &PathBuf::from("catalog-new.plugin"), None, + None, + None, )]) }) .unwrap(); diff --git a/windows/tauri/src-tauri/src/plugin_host/vcs.rs b/windows/tauri/src-tauri/src/plugin_host/vcs.rs new file mode 100644 index 0000000000..904f9fe531 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/vcs.rs @@ -0,0 +1,460 @@ +//! Native VCS root queries and isolated, short-lived typed provider sessions. +//! Accepted package bytes use the existing trust and mutation lease; Core owns +//! detection and ownership. Snapshots live only in memory and never in a bundle. +#[path = "vcs_actions.rs"] +pub(crate) mod actions; +#[path = "vcs_mappings.rs"] +pub(crate) mod mappings; +#[path = "vcs_query.rs"] +pub(crate) mod query; +#[path = "vcs_registry.rs"] +mod registry; +#[path = "vcs_session.rs"] +mod session; +#[path = "vcs_status.rs"] +pub(crate) mod status; +#[cfg(test)] +#[path = "vcs_tests.rs"] +mod tests; +#[path = "vcs_watch.rs"] +mod watch; + +pub use registry::{Ownership, Scope, Snapshot}; +use registry::{Package, Registry, VcsCore, READ_TIMEOUT, STALE}; +use serde_json::Value; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::Instant; +use tauri::{AppHandle, Manager, WebviewWindow}; +use tokio::sync::Semaphore; + +pub struct VcsHostState { + registry: Mutex, + reads: Arc, + sessions: Arc, + watches: watch::Watches, + actions: Mutex, +} +impl Default for VcsHostState { + fn default() -> Self { + Self { + registry: Mutex::new(Registry::default()), + reads: Arc::new(Semaphore::new(4)), + sessions: Arc::new(session::Sessions::default()), + watches: watch::Watches::default(), + actions: Mutex::new(actions::Actions::default()), + } + } +} +struct Core; +impl VcsCore for Core { + fn execute(&self, command: &str, payload: Value) -> Result { + super::scan::core_execute(command, payload) + } +} + +fn live(app: &AppHandle, host: &str) -> Result<(), String> { + if (host == "main" || host.starts_with("workspace-")) && app.get_webview_window(host).is_some() + { + Ok(()) + } else { + Err(STALE.into()) + } +} +fn validate(scope: &Scope) -> Result<(), String> { + if [&scope.workspace_id, &scope.incarnation] + .iter() + .any(|value| value.is_empty() || value.len() > 1024 || value.chars().any(char::is_control)) + || scope.workspace_root.len() > 32768 + || scope.workspace_root.contains('\0') + || !Path::new(&scope.workspace_root).is_absolute() + { + return Err("invalidRequest".into()); + } + Ok(()) +} + +/// Called only on the blocking pool. The real native directory handle and +/// project claim bind the frontend incarnation to this exact registration. +fn project_registration(app: &AppHandle, host: &str, scope: &Scope) -> Result { + let identity = crate::project_window_registry::ProjectWindowRegistry::identity(Path::new( + &scope.workspace_root, + )) + .map_err(|_| STALE)?; + app.state::() + .0 + .blocking_lock() + .registration(&identity, host, &scope.workspace_id) + .ok_or_else(|| STALE.into()) +} + +/// Retain package leases through both the Core read and final identity check. +/// Sorting makes identity comparison independent of catalog enumeration order. +fn packages(app: &AppHandle) -> Result<(Vec, Vec), String> { + let mut candidates: Vec<_> = app + .state::() + .packages() + .into_iter() + .filter(|package| { + package.entry.status == "accepted" + && package.entry.enabled + && package.manifest["vcsProviders"] + .as_array() + .is_some_and(|rows| !rows.is_empty()) + && !app + .state::() + .blocked(&package.entry.plugin_id) + }) + .collect(); + candidates.sort_by(|left, right| left.entry.plugin_id.cmp(&right.entry.plugin_id)); + let mut leases = Vec::new(); + let mut inputs = Vec::new(); + for candidate in candidates { + leases.push( + app.state::() + .acquire(&candidate.entry.plugin_id)?, + ); + let package = super::launch::verified_package_by_id(app, &candidate.entry.plugin_id)?; + inputs.push(Package { + plugin_id: package.entry.plugin_id, + identity: format!( + "{}:{}", + package.root.canonicalize().map_err(|_| STALE)?.display(), + super::signature::content_digest(&package.root).map_err(|_| STALE)? + ), + manifest: package.manifest, + }); + } + Ok((inputs, leases)) +} + +#[tauri::command] +pub async fn declarative_plugins_vcs_detect( + app: AppHandle, + caller: WebviewWindow, + scope: Scope, +) -> Result { + live(&app, caller.label())?; + validate(&scope)?; + let started = Instant::now(); + let state = app.state::(); + let deadline = tokio::time::Instant::from_std(started + READ_TIMEOUT); + let permit = tokio::time::timeout_at(deadline, state.reads.clone().acquire_owned()) + .await + .map_err(|_| "requestTimeout")? + .map_err(|_| "requestCancelled")?; + let worker_app = app.clone(); + let host = caller.label().to_string(); + // The permit remains in the blocking worker if the caller times out: at + // most four filesystem reads can survive a late OS read. No late publish. + let worker = tauri::async_runtime::spawn_blocking(move || { + let _permit = permit; + live(&worker_app, &host)?; + if Instant::now() >= started + READ_TIMEOUT { + return Err("requestTimeout".into()); + } + let registration = project_registration(&worker_app, &host, &scope)?; + let sessions = worker_app.state::().sessions.clone(); + for owner in sessions.matching(|context| { + context.host == host && context.scope.workspace_id == scope.workspace_id + }) { + status::cleanup_later(sessions.clone(), owner); + } + let owned = worker_app + .state::() + .registry + .lock() + .map_err(|_| "internalError")? + .begin(&host, scope.clone(), registration, started)?; + worker_app.state::().watches.remove_older( + &host, + &scope.workspace_id, + owned.generation, + ); + let result = (|| { + let (inputs, _leases) = packages(&worker_app)?; + let mappings = if inputs.is_empty() { + None + } else { + Some(mappings::read(&worker_app, &scope)?) + }; + let entries = mappings + .as_ref() + .map(|source| source.entries.as_slice()) + .unwrap_or(&[]); + let detection = registry::detect_mapped(&Core, &scope, &inputs, entries)?; + let choices = if entries.is_empty() { + detection.ambiguous.clone() + } else { + registry::detect(&Core, &scope, &inputs)?.ambiguous + }; + if packages(&worker_app)?.0 != inputs { + return Err(STALE.into()); + } + live(&worker_app, &host)?; + if project_registration(&worker_app, &host, &scope)? != owned.registration { + return Err(STALE.into()); + } + let published = worker_app + .state::() + .registry + .lock() + .map_err(|_| "internalError")? + .publish(&owned, detection.clone(), inputs.clone(), Instant::now())?; + // With no accepted provider, keep the passive Git path independent + // of the gated mapping store and do not allocate VCS watches. + let Some(mappings) = mappings else { + return Ok(published); + }; + worker_app + .state::() + .registry + .lock() + .map_err(|_| "internalError")? + .annotate(&owned, mappings.clone(), None, choices.clone())?; + let watch_error = watch::install( + &worker_app, + &host, + &scope, + owned.generation, + &detection, + &mappings.source, + ) + .err(); + if mappings::read(&worker_app, &scope)? != mappings + || !registry::same_roots( + &detection, + ®istry::detect_mapped(&Core, &scope, &inputs, &mappings.entries)?, + ) + { + return Err(STALE.into()); + } + if Instant::now() >= owned.deadline { + return Err("requestTimeout".into()); + } + worker_app + .state::() + .registry + .lock() + .map_err(|_| "internalError")? + .annotate(&owned, mappings, watch_error, choices) + })(); + if result.is_err() { + worker_app + .state::() + .registry + .lock() + .map_err(|_| "internalError")? + .discard(&owned); + worker_app + .state::() + .watches + .remove_generation(&host, &scope, owned.generation); + } + result + }); + let result = tokio::time::timeout_at(deadline, worker) + .await + .map_err(|_| "requestTimeout".to_string()) + .and_then(|value| value.map_err(|_| "internalError".to_string())) + .and_then(|value| value); + result +} + +#[tauri::command] +pub async fn declarative_plugins_vcs_owners( + app: AppHandle, + caller: WebviewWindow, + scope: Scope, + generation: u64, + paths: Vec, +) -> Result { + live(&app, caller.label())?; + validate(&scope)?; + if paths.len() > 100_000 + || paths.iter().any(|path| path.len() > 32768) + || paths + .iter() + .try_fold(0usize, |total, path| total.checked_add(path.len())) + .is_none_or(|bytes| bytes > 32 * 1024 * 1024) + { + return Err("invalidRequest".into()); + } + let (snapshot, expected, registration) = app + .state::() + .registry + .lock() + .map_err(|_| "internalError")? + .capture(caller.label(), &scope, generation)?; + let host = caller.label().to_string(); + let deadline = tokio::time::Instant::now() + READ_TIMEOUT; + let permit = tokio::time::timeout_at( + deadline, + app.state::().reads.clone().acquire_owned(), + ) + .await + .map_err(|_| "requestTimeout")? + .map_err(|_| "requestCancelled")?; + tokio::time::timeout_at( + deadline, + tauri::async_runtime::spawn_blocking(move || { + let _permit = permit; + if project_registration(&app, &host, &scope)? != registration { + return Err(STALE.into()); + } + let (current, _leases) = packages(&app)?; + if current != expected { + return Err(STALE.into()); + } + // Static markers may have changed since the list was rendered. + // Never report an enclosing owner after a new nested/ambiguous + // root appears; the next explicit refresh replaces the snapshot. + let fresh = redetect(&app, &scope, ¤t, &snapshot)?; + if !registry::same_roots(&snapshot.detection, &fresh) { + return Err(STALE.into()); + } + let result = registry::owners(&Core, &snapshot, &paths)?; + if packages(&app)?.0 != expected { + return Err(STALE.into()); + } + live(&app, &host)?; + if project_registration(&app, &host, &scope)? != registration { + return Err(STALE.into()); + } + app.state::() + .registry + .lock() + .map_err(|_| "internalError")? + .capture(&host, &scope, generation)?; + Ok(result) + }), + ) + .await + .map_err(|_| "requestTimeout".to_string())? + .map_err(|_| "internalError".to_string())? +} + +#[tauri::command] +pub async fn declarative_plugins_vcs_close( + app: AppHandle, + caller: WebviewWindow, + scope: Scope, +) -> Result<(), String> { + live(&app, caller.label())?; + validate(&scope)?; + app.state::() + .registry + .lock() + .map_err(|_| "internalError")? + .retire(caller.label(), &scope)?; + app.state::() + .watches + .remove(caller.label(), &scope); + app.state::() + .actions + .lock() + .expect("VCS actions") + .retire(|context| context.host == caller.label() && context.scope == scope); + status::close_matching(&app, |context| { + context.host == caller.label() && context.scope == scope + }) + .await +} +pub async fn plugin_changing(app: &AppHandle, plugin_id: &str) -> Result<(), String> { + if let Some(state) = app.try_state::() { + state.registry.lock().expect("VCS registry").invalidate(); + state.watches.clear(); + state.actions.lock().expect("VCS actions").retire(|_| true); + } + status::close_matching(app, |context| context.plugin_id == plugin_id).await +} + +/// Settings reload drains only this plugin in its original workspace window. +/// The shared settings revision rejects late preparation across this drain. +pub(crate) async fn close_scope( + app: &AppHandle, + host: &str, + workspace: &str, + plugin: &str, +) -> Result<(), String> { + if let Some(state) = app.try_state::() { + state + .actions + .lock() + .expect("VCS actions") + .retire(|context| context.settings_scope(host, workspace, plugin)); + } + status::close_matching(app, |context| { + context.settings_scope(host, workspace, plugin) + }) + .await +} +pub fn window_destroyed(app: &AppHandle, host: &str) { + if let Some(state) = app.try_state::() { + state.watches.retire_window(host, || { + state + .registry + .lock() + .expect("VCS registry") + .close_window(host); + }); + state + .actions + .lock() + .expect("VCS actions") + .retire(|context| context.host == host); + let sessions = state.sessions.clone(); + for owner in sessions.matching(|context| context.host == host) { + status::cleanup_later(sessions.clone(), owner); + } + } +} + +/// Native VCS transports never expose a generic JSON-RPC tunnel or event +/// queue to a product/web page, even one from the same native window. +pub(crate) fn private_core_request(app: &AppHandle, command: &str, payload: &Value) -> bool { + command == "lsp.vcsRequest" + || app + .state::() + .sessions + .private_session(command, payload) +} + +/// Re-read the exact Lithe mapping source for every ownership/status check. +fn redetect( + app: &AppHandle, + scope: &Scope, + packages: &[Package], + snapshot: &Snapshot, +) -> Result { + if packages.is_empty() && snapshot.mappings.is_none() { + return registry::detect(&Core, scope, packages); + } + let mappings = mappings::read(app, scope)?; + if snapshot.mappings.as_ref() != Some(&mappings) { + return Err(STALE.into()); + } + registry::detect_mapped(&Core, scope, packages, &mappings.entries) +} + +fn metadata_changed(app: &AppHandle, host: &str, scope: &Scope, generation: u64) { + use tauri::Emitter; + let state = app.state::(); + // The registry's write fence lasts through process cleanup and delayed + // callbacks until the action controller's explicit scan replaces it. + if !state + .registry + .lock() + .expect("VCS registry") + .invalidate_generation(host, scope, generation) + { + return; + } + let sessions = state.sessions.clone(); + for owner in sessions.matching(|context| { + context.host == host && context.scope == *scope && context.generation == generation + }) { + status::cleanup_later(sessions.clone(), owner); + } + let _ = app.emit_to(host, "vcs-roots-invalidated", serde_json::json!({"workspaceId":scope.workspace_id,"incarnation":scope.incarnation,"generation":generation})); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/vcs_actions.rs b/windows/tauri/src-tauri/src/plugin_host/vcs_actions.rs new file mode 100644 index 0000000000..968ea2455f --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/vcs_actions.rs @@ -0,0 +1,393 @@ +//! Snapshot-bound writes. UI confirms an explicit selection; native code +//! consumes the status receipt once, re-reads it and proves Core ownership +//! again before dispatch. Cleanup retries never dispatch another mutation. +use super::{query, registry, session, Scope}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::Path; + +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "operation", rename_all = "camelCase", deny_unknown_fields)] +pub enum WriteOperation { + Checkin { paths: Vec, message: String }, + Rollback { paths: Vec }, + Update, + Resolve { paths: Vec }, + Add { paths: Vec }, + Delete { paths: Vec }, + // An empty struct makes deny_unknown_fields reject narrower path/revision + // arguments; internally tagged unit variants would silently ignore them. + Cleanup {}, +} +impl WriteOperation { + pub(super) fn name(&self) -> &'static str { + match self { + Self::Checkin { .. } => "checkin", + Self::Rollback { .. } => "rollback", + Self::Update => "update", + Self::Resolve { .. } => "resolve", + Self::Cleanup { .. } => "cleanup", + Self::Add { .. } => "add", + Self::Delete { .. } => "delete", + } + } + pub(super) fn request_name(&self) -> &'static str { + match self { + Self::Add { .. } | Self::Delete { .. } => "fileOperation", + _ => self.name(), + } + } + pub(super) fn capability(&self) -> &'static str { + match self { + Self::Resolve { .. } => "conflicts", + Self::Add { .. } | Self::Delete { .. } => "fileOperations", + _ => self.name(), + } + } + pub(super) fn arguments(&self) -> Value { + match self { + Self::Checkin { paths, message } => json!({"paths":paths,"message":message}), + Self::Rollback { paths } | Self::Resolve { paths } => json!({"paths":paths}), + Self::Update | Self::Cleanup { .. } => json!({}), + Self::Add { paths } => json!({"kind":"add","paths":paths}), + Self::Delete { paths } => json!({"kind":"delete","paths":paths}), + } + } +} + +/// UI selection rules are intentionally narrower than v1: it offers neither +/// recursion control nor a keep-local switch. Disk metadata is checked later; +/// a status row alone never proves an ordinary file. +fn file_operation_row(operation: &WriteOperation, path: &str, row: &Value) -> bool { + (match operation { + // No parent can be implicitly scheduled when the file is directly in + // the detected root. v1 cannot prove a clean-looking parent is tracked. + WriteOperation::Add { .. } => { + !path.contains('/') && row["status"] == "unversioned" && row["oldPath"].is_null() + } + WriteOperation::Delete { .. } => matches!( + row["status"].as_str(), + Some( + "modified" + | "added" + | "renamed" + | "copied" + | "replaced" + | "typeChanged" + | "switched" + ) + ), + _ => return true, + }) && !row["flags"].as_array().is_some_and(|flags| { + flags.iter().any(|flag| { + matches!( + flag.as_str(), + Some("external" | "submodule" | "treeConflict") + ) + }) + }) +} +#[derive(Clone)] +pub(super) struct Published { + pub context: session::Context, + pub raw: Value, + pub projected: Value, + pub capabilities: Value, +} +#[derive(Default)] +pub(super) struct Actions { + statuses: HashMap<(String, String), (Published, usize)>, + bytes: usize, +} +impl Actions { + pub fn publish(&mut self, published: Published) { + const MAX_BYTES: usize = 32 * 1024 * 1024; + let context = &published.context; + self.retire(|old| { + old.host == context.host && old.scope == context.scope && old.root.id == context.root.id + }); + let bytes = published.raw.to_string().len() + published.projected.to_string().len(); + if bytes > MAX_BYTES { + return; + } + if self.statuses.len() >= 128 || self.bytes + bytes > MAX_BYTES { + self.statuses.clear(); + self.bytes = 0; + } + self.bytes += bytes; + self.statuses.insert( + (context.host.clone(), context.request_id.clone()), + (published, bytes), + ); + } + pub fn take(&mut self, host: &str, scope: &Scope, id: &str) -> Result { + let key = (host.into(), id.into()); + if !self + .statuses + .get(&key) + .is_some_and(|(status, _)| status.context.scope == *scope) + { + return Err(registry::STALE.into()); + } + let (status, bytes) = self.statuses.remove(&key).ok_or(registry::STALE)?; + self.bytes -= bytes; + Ok(status) + } + pub fn retire(&mut self, predicate: impl Fn(&session::Context) -> bool) { + self.statuses.retain(|_, (status, bytes)| { + if predicate(&status.context) { + self.bytes -= *bytes; + false + } else { + true + } + }); + } +} + +pub(super) fn same_source(published: &Published, context: &session::Context) -> bool { + let mut original = published.context.clone(); + original.request_id = context.request_id.clone(); + original == *context +} + +/// Expand a selected rename/copy to both paths, then ask Core about every +/// endpoint. Only existing ordinary files prove a non-recursive selection; +/// version 1 status cannot distinguish a missing file from a missing directory. +pub(super) fn targets( + core: &dyn registry::VcsCore, + snapshot: ®istry::Snapshot, + root: ®istry::Root, + status: &Published, + operation: &WriteOperation, +) -> Result { + if snapshot.detection.truncated + || status.raw["truncated"] != false + || status.projected["truncated"] != false + { + return Err("vcs.provider.incompleteRoots".into()); + } + if status.capabilities[operation.capability()] != 1 { + return Err("vcs.capability.unsupported".into()); + } + let workspace = Path::new(&snapshot.workspace_root) + .canonicalize() + .map_err(|_| registry::STALE)?; + let root_path = Path::new(&root.path) + .canonicalize() + .map_err(|_| registry::STALE)?; + if matches!( + operation, + WriteOperation::Update | WriteOperation::Cleanup { .. } + ) { + // v1 update/cleanup have no path scope. Never mutate an enclosing working copy + // or one containing nested/ambiguous roots from a narrower window. + if root_path != workspace + || snapshot.detection.roots.len() != 1 + || !snapshot.detection.ambiguous.is_empty() + { + return Err("vcs.provider.pathUnavailable".into()); + } + let owners = registry::owners(core, snapshot, &[String::new()])?; + if !owners.complete + || owners.owners.len() != 1 + || owners.owners[0].root.as_deref() != Some(root.id.as_str()) + { + return Err("vcs.provider.pathUnavailable".into()); + } + return Ok(operation.clone()); + } + let (selected, message) = match operation { + WriteOperation::Checkin { paths, message } => { + if message.trim().is_empty() || message.len() > 1024 * 1024 || message.contains('\0') { + return Err("invalidRequest".into()); + } + (paths, Some(message)) + } + WriteOperation::Rollback { paths } + | WriteOperation::Resolve { paths } + | WriteOperation::Add { paths } + | WriteOperation::Delete { paths } => (paths, None), + _ => unreachable!(), + }; + if selected.is_empty() || selected.len() > 10000 { + return Err("invalidRequest".into()); + } + let changes = status.projected["changes"] + .as_array() + .ok_or("invalidResponse")?; + let rows: BTreeMap<&str, &Value> = changes + .iter() + .filter_map(|change| change["path"].as_str().map(|path| (path, change))) + .collect(); + let mut paths = BTreeSet::new(); + for path in selected { + query::validate_path(path)?; + let row = rows.get(path.as_str()).ok_or(registry::STALE)?; + if !file_operation_row(operation, path, row) { + return Err("vcs.provider.pathUnavailable".into()); + } + if row["status"] == "ignored" + || (matches!(operation, WriteOperation::Rollback { .. }) + && row["status"] == "unversioned") + { + return Err("invalidRequest".into()); + } + if matches!(operation, WriteOperation::Resolve { .. }) + && row["status"] != "conflicted" + && !row["flags"] + .as_array() + .is_some_and(|flags| flags.iter().any(|flag| flag == "treeConflict")) + { + return Err("invalidRequest".into()); + } + paths.insert(path.clone()); + // macOS resolve marks the selected conflict path, never its oldPath. + if !matches!(operation, WriteOperation::Resolve { .. }) { + if let Some(old) = row["oldPath"].as_str() { + paths.insert(old.into()); + } + } + } + if paths.len() > 10000 { + return Err("invalidRequest".into()); + } + let paths: Vec = paths.into_iter().collect(); + for path in &paths { + query::validate_path(path)?; + // A rename/copy's old endpoint may also have an independent status row + // (for example, a recreated unversioned file). Expansion must not bypass + // the same delete eligibility applied to explicitly selected rows. + if matches!(operation, WriteOperation::Delete { .. }) + && rows + .get(path.as_str()) + .is_some_and(|row| !file_operation_row(operation, path, row)) + { + return Err("vcs.provider.pathUnavailable".into()); + } + let target = root_path.join(path); + query::ordinary_chain(&workspace, &target)?; + let canonical = target + .canonicalize() + .map_err(|_| "vcs.provider.pathUnavailable")?; + // A removed directory may be represented by a single status row. Its + // provider rollback can recurse into children absent from confirmation. + // Core IDs are lexical. Windows case/short-name aliases must not hide + // a nested working copy or a scheduled parent from the ownership check. + if canonical != target + || !std::fs::symlink_metadata(&target).is_ok_and(|metadata| metadata.is_file()) + || rows + .range(format!("{path}/").as_str()..) + .next() + .is_some_and(|(other, _)| other.starts_with(&format!("{path}/"))) + || snapshot + .detection + .roots + .iter() + .any(|nested| nested.id != root.id && Path::new(&nested.path).starts_with(&target)) + || snapshot.detection.ambiguous.iter().any(|ambiguous| { + ambiguous["id"] + .as_str() + .is_some_and(|id| workspace.join(id).starts_with(&target)) + }) + { + return Err("vcs.provider.pathUnavailable".into()); + } + if message.is_some() + || matches!( + operation, + WriteOperation::Resolve { .. } | WriteOperation::Delete { .. } + ) + { + let mut ancestor = path.as_str(); + while let Some((parent, _)) = ancestor.rsplit_once('/') { + // Providers may include scheduled parent additions/replacements + // implicitly. A selected child must not commit an unseen parent. + if rows.get(parent).is_some_and(|row| { + matches!( + row["status"].as_str(), + Some("added" | "replaced" | "renamed" | "copied") + ) || (matches!(operation, WriteOperation::Delete { .. }) + && (row["status"] == "unversioned" + || row["status"] == "ignored" + || row["flags"].as_array().is_some_and(|flags| { + flags.iter().any(|flag| { + matches!( + flag.as_str(), + Some("external" | "submodule" | "treeConflict") + ) + }) + }))) + }) { + return Err("vcs.provider.pathUnavailable".into()); + } + ancestor = parent; + } + } + } + let relative: Vec<_> = paths + .iter() + .map(|path| { + root_path + .join(path) + .strip_prefix(&workspace) + .ok() + .and_then(|path| path.to_str()) + .map(|path| path.replace('\\', "/")) + .ok_or_else(|| "vcs.provider.pathUnavailable".to_string()) + }) + .collect::>()?; + let owners = registry::owners(core, snapshot, &relative)?; + if !owners.complete + || owners.owners.len() != relative.len() + || owners + .owners + .iter() + .any(|owner| owner.root.as_deref() != Some(root.id.as_str())) + { + return Err("vcs.provider.pathUnavailable".into()); + } + Ok(match message { + Some(message) => WriteOperation::Checkin { + paths, + message: message.clone(), + }, + None if matches!(operation, WriteOperation::Resolve { .. }) => { + WriteOperation::Resolve { paths } + } + None if matches!(operation, WriteOperation::Add { .. }) => WriteOperation::Add { paths }, + None if matches!(operation, WriteOperation::Delete { .. }) => { + WriteOperation::Delete { paths } + } + None => WriteOperation::Rollback { paths }, + }) +} + +#[tauri::command] +pub async fn declarative_plugins_vcs_write( + app: tauri::AppHandle, + caller: tauri::WebviewWindow, + scope: Scope, + generation: u64, + root_id: String, + request_id: String, + status_id: String, + action: WriteOperation, +) -> Result { + super::status::run( + app, + caller, + scope, + generation, + root_id, + request_id, + query::ReadOperation::Status, + Some((status_id, action)), + ) + .await +} + +#[cfg(test)] +#[path = "vcs_actions_tests.rs"] +mod tests; diff --git a/windows/tauri/src-tauri/src/plugin_host/vcs_actions_tests.rs b/windows/tauri/src-tauri/src/plugin_host/vcs_actions_tests.rs new file mode 100644 index 0000000000..3e9ec469c8 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/vcs_actions_tests.rs @@ -0,0 +1,818 @@ +use super::*; +use std::sync::atomic::{AtomicU64, Ordering}; +static NEXT: AtomicU64 = AtomicU64::new(1); +struct Fixture(std::path::PathBuf); +impl Fixture { + fn new() -> Self { + let fixture = Self(std::env::temp_dir().join(format!( + "lithe-vcs-actions-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + ))); + std::fs::create_dir_all(fixture.0.join(".svn")).unwrap(); + fixture + } + fn snapshot(&self, workspace: &Path) -> registry::Snapshot { + let manifest: Value = serde_json::from_str(include_str!( + "../../../../../shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json" + )) + .unwrap(); + let scope = Scope { + workspace_id: "fixture".into(), + workspace_root: workspace.to_string_lossy().into_owned(), + incarnation: "one".into(), + }; + registry::Snapshot { + generation: 1, + workspace_id: scope.workspace_id.clone(), + workspace_root: scope.workspace_root.clone(), + detection: registry::detect( + &super::super::Core, + &scope, + &[registry::Package { + plugin_id: manifest["id"].as_str().unwrap().into(), + identity: "digest".into(), + manifest, + }], + ) + .unwrap(), + mappings: None, + watch_error: None, + mapping_choices: Vec::new(), + } + } + fn status(&self, snapshot: ®istry::Snapshot, changes: Value) -> Published { + let decoded = crate::plugin_host::scan::core_execute("vcs.decodeReply", json!({"operation":"status","request":{},"capabilities":{"status":1},"reply":{"changes":changes}})).unwrap(); + assert_eq!(decoded["status"], "ok"); + Published { + context: session::Context { + host: "workspace-fixture".into(), + scope: Scope { + workspace_id: "fixture".into(), + workspace_root: snapshot.workspace_root.clone(), + incarnation: "one".into(), + }, + generation: 1, + registration: 2, + request_id: "status-receipt".into(), + root: snapshot.detection.roots[0].clone(), + root_identity: "directory-identity".into(), + plugin_id: "fixture.plugin".into(), + package_identity: "digest".into(), + settings_revision: 0, + }, + raw: decoded["result"].clone(), + projected: decoded["result"].clone(), + capabilities: json!({"status":1,"checkin":1,"rollback":1,"update":1}), + } + } +} +impl Drop for Fixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).expect("remove action fixture"); + } +} +#[test] +fn real_core_owners_keep_existing_selected_endpoints_but_reject_cross_roots() { + let fixture = Fixture::new(); + for path in ["new.txt", "gone.txt", "after.txt", "before.txt"] { + std::fs::write(fixture.0.join(path), "fixture").unwrap(); + } + std::fs::create_dir_all(fixture.0.join("nested/.git")).unwrap(); + std::fs::create_dir_all(fixture.0.join("mixed/.svn")).unwrap(); + std::fs::create_dir_all(fixture.0.join("mixed/.git")).unwrap(); + let snapshot = fixture.snapshot(&fixture.0); + let root = &snapshot.detection.roots[0]; + let status = fixture.status( + &snapshot, + json!([ + {"path":"new.txt","status":"added"}, {"path":"gone.txt","status":"deleted"}, + {"path":"after.txt","oldPath":"before.txt","status":"renamed"}, + {"path":"bad.txt","oldPath":"nested/old.txt","status":"renamed"}, + {"path":"mixed/a.txt","status":"modified"} + ]), + ); + let action = WriteOperation::Checkin { + paths: vec!["new.txt".into(), "gone.txt".into(), "after.txt".into()], + message: "Selected changes".into(), + }; + let expanded = targets(&super::super::Core, &snapshot, root, &status, &action).unwrap(); + assert_eq!( + expanded.arguments(), + json!({"paths":["after.txt","before.txt","gone.txt","new.txt"],"message":"Selected changes"}) + ); + // Canonicalize the root before joining endpoints, so Windows' ordinary + // absolute spelling is not rejected merely for lacking the verbatim prefix. + let mut ordinary_root = root.clone(); + ordinary_root.path = fixture.0.to_string_lossy().into_owned(); + assert!(targets( + &super::super::Core, + &snapshot, + &ordinary_root, + &status, + &action + ) + .is_ok()); + for path in ["bad.txt", "mixed/a.txt", "not-selected.txt"] { + assert!(targets( + &super::super::Core, + &snapshot, + root, + &status, + &WriteOperation::Rollback { + paths: vec![path.into()] + } + ) + .is_err()); + } + assert!(targets( + &super::super::Core, + &snapshot, + root, + &status, + &WriteOperation::Update + ) + .is_err()); +} +#[test] +fn update_requires_exact_whole_unambiguous_working_copy_and_complete_snapshot() { + let fixture = Fixture::new(); + let mut snapshot = fixture.snapshot(&fixture.0); + let mut status = fixture.status(&snapshot, json!([])); + assert!(targets( + &super::super::Core, + &snapshot, + &snapshot.detection.roots[0], + &status, + &WriteOperation::Update + ) + .is_ok()); + status.projected["truncated"] = json!(true); + assert!(targets( + &super::super::Core, + &snapshot, + &snapshot.detection.roots[0], + &status, + &WriteOperation::Update + ) + .is_err()); + std::fs::create_dir_all(fixture.0.join("inside")).unwrap(); + snapshot = fixture.snapshot(&fixture.0.join("inside")); + status = fixture.status(&snapshot, json!([])); + assert!(targets( + &super::super::Core, + &snapshot, + &snapshot.detection.roots[0], + &status, + &WriteOperation::Update + ) + .is_err()); +} +#[test] +fn receipts_are_one_shot_scope_owned_and_replacement_identity_cannot_inherit_confirmation() { + let fixture = Fixture::new(); + let snapshot = fixture.snapshot(&fixture.0); + let published = fixture.status(&snapshot, json!([])); + let context = published.context.clone(); + let mut actions = Actions::default(); + actions.publish(published.clone()); + assert!(actions + .take("another-window", &context.scope, "status-receipt") + .is_err()); + let mut scope = context.scope.clone(); + scope.incarnation = "replacement".into(); + assert!(actions + .take(&context.host, &scope, "status-receipt") + .is_err()); + assert!(actions + .take(&context.host, &context.scope, "status-receipt") + .is_ok()); + assert!(actions + .take(&context.host, &context.scope, "status-receipt") + .is_err()); + let mut changed = context.clone(); + changed.package_identity = "replaced-bytes".into(); + assert!(!same_source(&published, &changed)); + changed = context.clone(); + changed.registration += 1; + assert!(!same_source(&published, &changed)); + changed = context.clone(); + changed.root_identity = "replacement-directory".into(); + assert!(!same_source(&published, &changed)); + changed = context.clone(); + changed.settings_revision += 1; + assert!(!same_source(&published, &changed)); +} +#[test] +fn ignored_unversioned_rollback_recursive_directories_and_unselected_paths_are_rejected() { + let fixture = Fixture::new(); + std::fs::create_dir_all(fixture.0.join("folder")).unwrap(); + let snapshot = fixture.snapshot(&fixture.0); + let status = fixture.status( + &snapshot, + json!([ + {"path":"ignored.txt","status":"ignored"}, {"path":"new.txt","status":"unversioned"}, + {"path":"folder","status":"modified"}, {"path":"missing-folder","status":"missing"}, + {"path":"missing-folder/child.txt","status":"missing"} + ]), + ); + for path in [ + "ignored.txt", + "new.txt", + "folder", + "missing-folder", + "../outside.txt", + ] { + assert!(targets( + &super::super::Core, + &snapshot, + &snapshot.detection.roots[0], + &status, + &WriteOperation::Rollback { + paths: vec![path.into()] + } + ) + .is_err()); + } +} + +#[test] +fn absent_endpoints_cannot_prove_a_file_only_write_even_without_descendant_status_rows() { + let fixture = Fixture::new(); + std::fs::create_dir_all(fixture.0.join("removed-directory")).unwrap(); + std::fs::write(fixture.0.join("removed-directory/unlisted.txt"), "fixture").unwrap(); + std::fs::remove_dir_all(fixture.0.join("removed-directory")).unwrap(); + std::fs::write(fixture.0.join("after.txt"), "fixture").unwrap(); + let snapshot = fixture.snapshot(&fixture.0); + let status = fixture.status( + &snapshot, + json!([ + {"path":"removed-directory","status":"missing"}, + {"path":"gone.txt","status":"deleted"}, + {"path":"after.txt","oldPath":"before.txt","status":"renamed"} + ]), + ); + for path in ["removed-directory", "gone.txt", "after.txt"] { + for action in [ + WriteOperation::Checkin { + paths: vec![path.into()], + message: "Only the confirmed file".into(), + }, + WriteOperation::Rollback { + paths: vec![path.into()], + }, + ] { + assert_eq!( + targets( + &super::super::Core, + &snapshot, + &snapshot.detection.roots[0], + &status, + &action + ) + .unwrap_err(), + "vcs.provider.pathUnavailable" + ); + } + } +} + +#[test] +fn checkin_rejects_implicit_scheduled_parent_changes_but_keeps_unrelated_files_available() { + let fixture = Fixture::new(); + let mut changes = vec![json!({"path":"other.txt","status":"modified"})]; + std::fs::write(fixture.0.join("other.txt"), "fixture").unwrap(); + for status in ["added", "replaced", "renamed", "copied"] { + let parent = format!("{status}-parent"); + std::fs::create_dir_all(fixture.0.join(&parent).join("inside")).unwrap(); + std::fs::write(fixture.0.join(&parent).join("inside/child.txt"), "fixture").unwrap(); + changes.push(json!({"path":parent,"status":status})); + changes.push(json!({"path":format!("{parent}/inside/child.txt"),"status":"added"})); + } + let snapshot = fixture.snapshot(&fixture.0); + let status = fixture.status(&snapshot, json!(changes)); + for parent in ["added", "replaced", "renamed", "copied"] { + assert_eq!( + targets( + &super::super::Core, + &snapshot, + &snapshot.detection.roots[0], + &status, + &WriteOperation::Checkin { + paths: vec![format!("{parent}-parent/inside/child.txt")], + message: "No implicit parent".into(), + } + ) + .unwrap_err(), + "vcs.provider.pathUnavailable" + ); + } + assert!(targets( + &super::super::Core, + &snapshot, + &snapshot.detection.roots[0], + &status, + &WriteOperation::Checkin { + paths: vec!["other.txt".into()], + message: "Only the confirmed file".into(), + } + ) + .is_ok()); +} + +#[cfg(windows)] +#[test] +fn windows_case_aliases_cannot_hide_nested_roots_or_implicit_parent_changes() { + let fixture = Fixture::new(); + std::fs::create_dir_all(fixture.0.join("nested/.git")).unwrap(); + std::fs::write(fixture.0.join("nested/file.txt"), "fixture").unwrap(); + std::fs::create_dir_all(fixture.0.join("AddedParent")).unwrap(); + std::fs::write(fixture.0.join("AddedParent/child.txt"), "fixture").unwrap(); + let snapshot = fixture.snapshot(&fixture.0); + let status = fixture.status( + &snapshot, + json!([ + {"path":"NESTED/file.txt","status":"modified"}, + {"path":"AddedParent","status":"added"}, + {"path":"addedparent/child.txt","status":"added"} + ]), + ); + for path in ["NESTED/file.txt", "addedparent/child.txt"] { + assert!(fixture.0.join(path).is_file(), "Windows resolves the alias"); + assert_eq!( + targets( + &super::super::Core, + &snapshot, + &snapshot.detection.roots[0], + &status, + &WriteOperation::Checkin { + paths: vec![path.into()], + message: "The spelling cannot bypass ownership".into(), + } + ) + .unwrap_err(), + "vcs.provider.pathUnavailable" + ); + } +} + +#[test] +fn resolve_requires_real_core_ownership_of_existing_ordinary_conflict_endpoints() { + let fixture = Fixture::new(); + std::fs::create_dir_all(fixture.0.join("folder")).unwrap(); + std::fs::create_dir_all(fixture.0.join("nested/.git")).unwrap(); + std::fs::create_dir_all(fixture.0.join("mixed/.svn")).unwrap(); + std::fs::create_dir_all(fixture.0.join("mixed/.git")).unwrap(); + for path in [ + "conflict.txt", + "tree.txt", + "clean.txt", + "nested/a.txt", + "mixed/a.txt", + ] { + std::fs::write(fixture.0.join(path), "fixture").unwrap(); + } + let snapshot = fixture.snapshot(&fixture.0); + let root = &snapshot.detection.roots[0]; + let mut status = fixture.status(&snapshot, json!([ + {"path":"conflict.txt","status":"conflicted"}, + {"path":"tree.txt","oldPath":"absent-old.txt","status":"renamed","flags":["treeConflict"]}, + {"path":"clean.txt","status":"modified"}, + {"path":"folder","status":"conflicted"}, + {"path":"missing.txt","status":"conflicted"}, + {"path":"nested/a.txt","status":"conflicted"}, + {"path":"mixed/a.txt","status":"conflicted"} + ])); + // The operation is named resolve, but its real negotiated capability is conflicts. + status.capabilities["conflicts"] = json!(1); + let action = WriteOperation::Resolve { + paths: vec!["tree.txt".into(), "conflict.txt".into(), "tree.txt".into()], + }; + assert_eq!( + targets(&super::super::Core, &snapshot, root, &status, &action) + .unwrap() + .arguments(), + json!({"paths":["conflict.txt","tree.txt"]}) + ); + for path in [ + "clean.txt", + "folder", + "missing.txt", + "nested/a.txt", + "mixed/a.txt", + "unlisted.txt", + "../outside.txt", + ] { + assert!( + targets( + &super::super::Core, + &snapshot, + root, + &status, + &WriteOperation::Resolve { + paths: vec![path.into()] + } + ) + .is_err(), + "{path}" + ); + } + for parent in ["added", "replaced", "renamed", "copied"] { + std::fs::create_dir_all(fixture.0.join(parent)).unwrap(); + std::fs::write(fixture.0.join(parent).join("child.txt"), "fixture").unwrap(); + let mut scheduled = fixture.status( + &snapshot, + json!([ + {"path":parent,"status":parent}, + {"path":format!("{parent}/child.txt"),"status":"conflicted"} + ]), + ); + scheduled.capabilities["conflicts"] = json!(1); + assert_eq!( + targets( + &super::super::Core, + &snapshot, + root, + &scheduled, + &WriteOperation::Resolve { + paths: vec![format!("{parent}/child.txt")] + } + ) + .unwrap_err(), + "vcs.provider.pathUnavailable" + ); + } + status.capabilities = json!({"resolve":1}); + assert_eq!( + targets(&super::super::Core, &snapshot, root, &status, &action).unwrap_err(), + "vcs.capability.unsupported" + ); +} + +#[test] +fn cleanup_requires_complete_receipt_and_exact_whole_root_without_nested_or_ambiguous_roots() { + let fixture = Fixture::new(); + let mut snapshot = fixture.snapshot(&fixture.0); + let mut status = fixture.status(&snapshot, json!([])); + status.capabilities["cleanup"] = json!(1); + let check = |snapshot: ®istry::Snapshot, status: &Published| { + targets( + &super::super::Core, + snapshot, + &snapshot.detection.roots[0], + status, + &WriteOperation::Cleanup {}, + ) + }; + assert_eq!(check(&snapshot, &status).unwrap().arguments(), json!({})); + status.raw["truncated"] = json!(true); + assert!(check(&snapshot, &status).is_err()); + status.raw["truncated"] = json!(false); + status.projected["truncated"] = json!(true); + assert!(check(&snapshot, &status).is_err()); + status.projected["truncated"] = json!(false); + snapshot.detection.truncated = true; + assert!(check(&snapshot, &status).is_err()); + snapshot.detection.truncated = false; + status.capabilities["cleanup"] = json!(0); + assert_eq!( + check(&snapshot, &status).unwrap_err(), + "vcs.capability.unsupported" + ); + status.capabilities["cleanup"] = json!(1); + std::fs::create_dir_all(fixture.0.join("inside")).unwrap(); + assert!(check(&fixture.snapshot(&fixture.0.join("inside")), &status).is_err()); + std::fs::create_dir_all(fixture.0.join("mixed/.git")).unwrap(); + std::fs::create_dir_all(fixture.0.join("mixed/.svn")).unwrap(); + assert!(check(&fixture.snapshot(&fixture.0), &status).is_err()); + std::fs::create_dir_all(fixture.0.join("nested/.git")).unwrap(); + assert!(check(&fixture.snapshot(&fixture.0), &status).is_err()); +} + +#[test] +fn resolve_and_cleanup_deserialize_only_the_frozen_v1_arguments() { + assert_eq!( + serde_json::from_value::(json!({"operation":"resolve","paths":["a.txt"]})) + .unwrap() + .arguments(), + json!({"paths":["a.txt"]}) + ); + assert_eq!( + serde_json::from_value::(json!({"operation":"cleanup"})) + .unwrap() + .arguments(), + json!({}) + ); + for input in [ + json!({"operation":"resolve","paths":["a.txt"],"accept":"working"}), + json!({"operation":"cleanup","paths":["a.txt"]}), + json!({"operation":"cleanup","revision":"r2"}), + ] { + assert!(serde_json::from_value::(input).is_err()); + } +} + +#[cfg(windows)] +#[test] +fn resolve_rejects_windows_case_aliases_before_lexical_core_ownership() { + let fixture = Fixture::new(); + std::fs::create_dir_all(fixture.0.join("nested/.git")).unwrap(); + std::fs::write(fixture.0.join("nested/file.txt"), "fixture").unwrap(); + let snapshot = fixture.snapshot(&fixture.0); + let mut status = fixture.status( + &snapshot, + json!([{"path":"NESTED/file.txt","status":"conflicted"}]), + ); + status.capabilities["conflicts"] = json!(1); + assert!(fixture.0.join("NESTED/file.txt").is_file()); + assert_eq!( + targets( + &super::super::Core, + &snapshot, + &snapshot.detection.roots[0], + &status, + &WriteOperation::Resolve { + paths: vec!["NESTED/file.txt".into()] + } + ) + .unwrap_err(), + "vcs.provider.pathUnavailable" + ); +} + +#[test] +fn file_operations_use_existing_ordinary_files_and_real_core_owners_without_parent_expansion() { + let fixture = Fixture::new(); + for directory in [ + "folder", + "parent", + "nested/.git", + "mixed/.git", + "mixed/.svn", + ] { + std::fs::create_dir_all(fixture.0.join(directory)).unwrap(); + } + for path in [ + "new.txt", + "after.txt", + "before.txt", + "parent/child.txt", + "nested/a.txt", + "mixed/a.txt", + ] { + std::fs::write(fixture.0.join(path), "fixture").unwrap(); + } + let snapshot = fixture.snapshot(&fixture.0); + let root = &snapshot.detection.roots[0]; + let mut status = fixture.status( + &snapshot, + json!([ + {"path":"new.txt","status":"unversioned"}, + {"path":"after.txt","oldPath":"before.txt","status":"renamed"}, + {"path":"parent/child.txt","status":"unversioned"}, + {"path":"folder","status":"unversioned"}, + {"path":"missing","status":"unversioned"}, + {"path":"nested/a.txt","status":"modified"}, + {"path":"mixed/a.txt","status":"modified"} + ]), + ); + status.capabilities["fileOperations"] = json!(1); + let add = WriteOperation::Add { + paths: vec!["new.txt".into(), "new.txt".into()], + }; + let delete = WriteOperation::Delete { + paths: vec!["after.txt".into()], + }; + assert_eq!( + targets(&super::super::Core, &snapshot, root, &status, &add) + .unwrap() + .arguments(), + json!({"kind":"add","paths":["new.txt"]}) + ); + assert_eq!( + targets(&super::super::Core, &snapshot, root, &status, &delete) + .unwrap() + .arguments(), + json!({"kind":"delete","paths":["after.txt","before.txt"]}) + ); + for path in [ + "parent/child.txt", + "folder", + "missing", + "after.txt", + "unlisted.txt", + ] { + assert!( + targets( + &super::super::Core, + &snapshot, + root, + &status, + &WriteOperation::Add { + paths: vec![path.into()] + } + ) + .is_err(), + "{path}" + ); + } + for path in [ + "new.txt", + "folder", + "missing", + "nested/a.txt", + "mixed/a.txt", + ] { + assert!( + targets( + &super::super::Core, + &snapshot, + root, + &status, + &WriteOperation::Delete { + paths: vec![path.into()] + } + ) + .is_err(), + "{path}" + ); + } + // Both confirmed endpoints need disk proof, even when a status row claims + // a rename and omits all descendant information about the absent endpoint. + std::fs::remove_file(fixture.0.join("before.txt")).unwrap(); + assert!(targets(&super::super::Core, &snapshot, root, &status, &delete).is_err()); + status.capabilities = json!({"add":1,"delete":1,"fileOperation":1}); + assert_eq!( + targets(&super::super::Core, &snapshot, root, &status, &add).unwrap_err(), + "vcs.capability.unsupported" + ); +} + +#[test] +fn delete_checks_independent_old_endpoint_rows_regardless_of_order_or_repeated_selection() { + let fixture = Fixture::new(); + for path in ["after.txt", "before.txt"] { + std::fs::write(fixture.0.join(path), "fixture").unwrap(); + } + let snapshot = fixture.snapshot(&fixture.0); + let root = &snapshot.detection.roots[0]; + for kind in ["renamed", "copied"] { + for (old_status, flags) in [ + ("unversioned", json!([])), + ("ignored", json!([])), + ("conflicted", json!([])), + ("modified", json!(["external"])), + ("modified", json!(["submodule"])), + ("modified", json!(["treeConflict"])), + ("modified", json!([])), + ] { + let current = json!({"path":"after.txt","oldPath":"before.txt","status":kind}); + let old = json!({"path":"before.txt","status":old_status,"flags":flags}); + for changes in [json!([current, old]), json!([old, current])] { + let mut status = fixture.status(&snapshot, changes); + status.capabilities["fileOperations"] = json!(1); + for selected in [ + vec!["after.txt"], + vec!["after.txt", "after.txt"], + vec!["before.txt", "after.txt", "before.txt"], + vec!["after.txt", "before.txt"], + ] { + let result = targets( + &super::super::Core, + &snapshot, + root, + &status, + &WriteOperation::Delete { + paths: selected.into_iter().map(String::from).collect(), + }, + ); + if old_status == "modified" && flags == json!([]) { + assert_eq!( + result.unwrap().arguments(), + json!({"kind":"delete","paths":["after.txt","before.txt"]}) + ); + } else { + assert_eq!(result.unwrap_err(), "vcs.provider.pathUnavailable"); + } + } + } + } + } +} + +#[test] +fn file_operations_reject_incomplete_receipts_unsafe_flags_and_implicit_scheduled_ancestors() { + let fixture = Fixture::new(); + std::fs::write(fixture.0.join("new.txt"), "fixture").unwrap(); + std::fs::create_dir_all(fixture.0.join("parent")).unwrap(); + std::fs::write(fixture.0.join("parent/child.txt"), "fixture").unwrap(); + let snapshot = fixture.snapshot(&fixture.0); + let root = &snapshot.detection.roots[0]; + for parent in [ + "added", + "replaced", + "renamed", + "copied", + "unversioned", + "ignored", + ] { + let mut status = fixture.status( + &snapshot, + json!([ + {"path":"parent","status":parent}, {"path":"parent/child.txt","status":"modified"} + ]), + ); + status.capabilities["fileOperations"] = json!(1); + assert!( + targets( + &super::super::Core, + &snapshot, + root, + &status, + &WriteOperation::Delete { + paths: vec!["parent/child.txt".into()] + } + ) + .is_err(), + "{parent}" + ); + } + for (kind, row_status) in [("add", "unversioned"), ("delete", "modified")] { + let action: WriteOperation = + serde_json::from_value(json!({"operation":kind,"paths":["new.txt"]})).unwrap(); + for flag in ["external", "submodule", "treeConflict"] { + let mut status = fixture.status( + &snapshot, + json!([{"path":"new.txt","status":row_status,"flags":[flag]}]), + ); + status.capabilities["fileOperations"] = json!(1); + assert!(targets(&super::super::Core, &snapshot, root, &status, &action).is_err()); + } + let mut status = fixture.status(&snapshot, json!([{"path":"new.txt","status":row_status}])); + status.capabilities["fileOperations"] = json!(1); + for field in ["raw", "projected"] { + if field == "raw" { + status.raw["truncated"] = json!(true); + } else { + status.projected["truncated"] = json!(true); + } + assert!(targets(&super::super::Core, &snapshot, root, &status, &action).is_err()); + status.raw["truncated"] = json!(false); + } + } +} + +#[test] +fn add_delete_deserialize_no_keep_local_recursion_or_arbitrary_provider_arguments() { + for kind in ["add", "delete"] { + let action: WriteOperation = + serde_json::from_value(json!({"operation":kind,"paths":["a.txt"]})).unwrap(); + assert_eq!(action.name(), kind); + assert_eq!(action.request_name(), "fileOperation"); + assert_eq!(action.capability(), "fileOperations"); + assert_eq!(action.arguments(), json!({"kind":kind,"paths":["a.txt"]})); + for field in [ + "keepLocal", + "recursive", + "depth", + "force", + "root", + "tool", + "kind", + ] { + let mut input = json!({"operation":kind,"paths":["a.txt"]}); + input[field] = json!(true); + assert!( + serde_json::from_value::(input).is_err(), + "{field}" + ); + } + } + assert!(serde_json::from_value::( + json!({"operation":"move","from":"a","to":"b"}) + ) + .is_err()); +} + +#[cfg(windows)] +#[test] +fn add_delete_reject_windows_case_aliases_before_core_lexical_ownership() { + let fixture = Fixture::new(); + std::fs::write(fixture.0.join("MixedCase.txt"), "fixture").unwrap(); + let snapshot = fixture.snapshot(&fixture.0); + let root = &snapshot.detection.roots[0]; + for (kind, row_status) in [("add", "unversioned"), ("delete", "modified")] { + let mut status = fixture.status( + &snapshot, + json!([{"path":"mixedcase.txt","status":row_status}]), + ); + status.capabilities["fileOperations"] = json!(1); + let action: WriteOperation = + serde_json::from_value(json!({"operation":kind,"paths":["mixedcase.txt"]})).unwrap(); + assert!(fixture.0.join("mixedcase.txt").is_file()); + assert_eq!( + targets(&super::super::Core, &snapshot, root, &status, &action).unwrap_err(), + "vcs.provider.pathUnavailable" + ); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/vcs_mappings.rs b/windows/tauri/src-tauri/src/plugin_host/vcs_mappings.rs new file mode 100644 index 0000000000..3af69fe8db --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/vcs_mappings.rs @@ -0,0 +1,392 @@ +//! Lithe-owned root choices, never SVN/Git configuration. Reads do not create +//! storage. Confirmed writes reuse the platform JSON conflict/ACL/recovery +//! adapter; mutable records and its temporaries never enter installed resources. +use super::{registry, Core, Scope}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use std::path::{Component, Path, PathBuf}; +use tauri::{AppHandle, Manager}; + +const MAX_BYTES: u64 = 1024 * 1024; +pub(super) const MAX_MAPPINGS: usize = 64; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Mapping { + pub id: String, + pub vcs: String, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MappingSnapshot { + pub source: String, + pub revision: Option, + pub entries: Vec, +} + +/// File IDs plus creation identity distinguish replacement directories, even +/// at the same path. The project registry retains its native Handle while open. +pub(super) fn directory_identity(path: &Path) -> Result { + let handle = same_file::Handle::from_path(path).map_err(|_| registry::STALE)?; + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, + }; + let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(handle.as_raw_handle() as _, &mut info) } == 0 { + return Err(registry::STALE.into()); + } + Ok(format!( + "{:x}-{:x}-{:x}-{:x}-{:x}", + info.dwVolumeSerialNumber, + info.nFileIndexHigh, + info.nFileIndexLow, + info.ftCreationTime.dwHighDateTime, + info.ftCreationTime.dwLowDateTime + )) + } + #[cfg(not(windows))] + { + use std::os::unix::fs::MetadataExt; + let _handle = handle; + let metadata = std::fs::metadata(path).map_err(|_| registry::STALE)?; + Ok(format!("{}-{}", metadata.dev(), metadata.ino())) + } +} +fn source(app: &AppHandle, scope: &Scope) -> Result { + let identity = directory_identity(Path::new(&scope.workspace_root))?; + let path = app + .path() + .app_local_data_dir() + .map_err(|_| "vcs.mapping.readFailed")? + .join("vcs-roots") + .join(format!("{:x}.json", Sha256::digest(identity.as_bytes()))); + // Do not follow an application-data junction into an installed package. + // Missing directories remain missing until an explicit confirmed Save. + storage_path(&path)?; + Ok(path) +} +fn storage_path(path: &Path) -> Result<(), String> { + for ancestor in path.ancestors() { + match std::fs::symlink_metadata(ancestor) { + Ok(metadata) => { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return Err("vcs.mapping.readOnly".into()); + } + } + if metadata.file_type().is_symlink() { + return Err("vcs.mapping.readOnly".into()); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err("vcs.mapping.readFailed".into()), + } + } + Ok(()) +} +fn store_error(error: String) -> String { + if error.starts_with("vcs.mapping.") { + return error; + } + if matches!( + error.as_str(), + "requestCancelled" | "requestTimeout" | "invalidRequest" + ) { + return error; + } + if error == "plugin.settings.conflict" { + "vcs.mapping.conflict".into() + } else if error == "plugin.settings.readOnlySource" { + "vcs.mapping.readOnly".into() + } else { + "vcs.mapping.readFailed".into() + } +} +fn values(path: &Path) -> Result<(Map, Option), String> { + if std::fs::metadata(path).is_ok_and(|metadata| metadata.len() > MAX_BYTES) { + return Err("vcs.mapping.readFailed".into()); + } + let result = + super::super::settings::read_object_snapshot(path, MAX_BYTES).map_err(store_error)?; + if !result.0.is_empty() && result.0.get("version") != Some(&json!(1)) { + return Err("vcs.mapping.readOnly".into()); + } + if let Some(rows) = result.0.get("mappings") { + if !rows + .as_array() + .is_some_and(|rows| rows.len() <= MAX_MAPPINGS) + { + return Err("vcs.mapping.readFailed".into()); + } + } + Ok(result) +} +fn root_path(workspace: &Path, id: &str) -> Result { + // Enclosing Core IDs contain only '..' components. Normal IDs never climb + // out and then back in. Reject absolute/drive/UNC aliases and reparse hops. + if id.len() > 32768 || id.contains('\\') || id.contains(':') || id.contains('\0') { + return Err("invalidRequest".into()); + } + let mut path = workspace.canonicalize().map_err(|_| registry::STALE)?; + let enclosing = !id.is_empty() && id.split('/').all(|part| part == ".."); + if !id.is_empty() { + for part in Path::new(id).components() { + match part { + Component::ParentDir if enclosing => { + if !path.pop() { + return Err("invalidRequest".into()); + } + } + Component::Normal(name) if !enclosing => { + path.push(name); + let metadata = std::fs::symlink_metadata(&path).map_err(|_| registry::STALE)?; + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return Err(registry::STALE.into()); + } + } + if metadata.file_type().is_symlink() { + return Err(registry::STALE.into()); + } + } + _ => return Err("invalidRequest".into()), + } + } + if !enclosing + && id + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + { + return Err("invalidRequest".into()); + } + } + Ok(path) +} +pub(super) fn read_at(path: &Path, workspace: &Path) -> Result { + let (values, revision) = values(path)?; + let mut entries = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for row in values + .get("mappings") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + // Unknown fields/rows survive saves. Only rows bound to the current + // directory object are input to Core; stale choices cannot claim paths. + let Some(id) = row["id"].as_str() else { + continue; + }; + if !seen.insert(id) { + return Err("vcs.mapping.readFailed".into()); + } + let Some(vcs) = row["vcs"].as_str() else { + continue; + }; + let Ok(root) = root_path(workspace, id) else { + continue; + }; + let Ok(identity) = directory_identity(&root) else { + continue; + }; + if row["directoryIdentity"] == identity { + entries.push(Mapping { + id: id.into(), + vcs: vcs.into(), + }); + } + } + entries.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(MappingSnapshot { + source: path.to_string_lossy().into_owned(), + revision, + entries, + }) +} +pub(super) fn read(app: &AppHandle, scope: &Scope) -> Result { + read_at(&source(app, scope)?, Path::new(&scope.workspace_root)) +} +pub(super) fn save_at( + path: &Path, + workspace: &Path, + expected: &MappingSnapshot, + id: &str, + vcs: Option<&str>, + current: impl FnOnce() -> Result<(), String>, +) -> Result { + let (mut object, revision) = values(path)?; + if revision != expected.revision || read_at(path, workspace)? != *expected { + return Err("vcs.mapping.conflict".into()); + } + let root = root_path(workspace, id)?; + let identity = directory_identity(&root)?; + if expected + .entries + .iter() + .find(|entry| entry.id == id) + .map(|entry| entry.vcs.as_str()) + == vcs + { + current()?; + return Ok(expected.clone()); + } + let rows = object + .entry("mappings") + .or_insert(json!([])) + .as_array_mut() + .ok_or("vcs.mapping.readFailed")?; + if let Some(vcs) = vcs { + if let Some(row) = rows.iter_mut().find(|row| row["id"] == id) { + if !row["vcs"].is_string() || !row["directoryIdentity"].is_string() { + return Err("vcs.mapping.readOnly".into()); + } + let fields = row.as_object_mut().ok_or("vcs.mapping.readFailed")?; + fields.insert("vcs".into(), json!(vcs)); + fields.insert("directoryIdentity".into(), json!(identity)); + } else { + if rows.len() >= MAX_MAPPINGS { + return Err("requestLimitExceeded".into()); + } + rows.push(json!({"id":id,"vcs":vcs,"directoryIdentity":identity})); + } + } else { + rows.retain(|row| row["id"] != id || row["directoryIdentity"] != identity); + } + object.insert("version".into(), json!(1)); + if serde_json::to_vec_pretty(&object) + .map_err(|_| "vcs.mapping.readFailed")? + .len() as u64 + > MAX_BYTES + { + return Err("requestLimitExceeded".into()); + } + super::super::settings::write_object_snapshot( + path, + &object, + &revision, + MAX_BYTES, + || storage_path(path), + || { + if directory_identity(&root)? != identity { + return Err(registry::STALE.into()); + } + current() + }, + ) + .map_err(store_error)?; + read_at(path, workspace) +} + +#[tauri::command] +pub async fn declarative_plugins_vcs_save_mapping( + app: AppHandle, + caller: tauri::WebviewWindow, + scope: Scope, + generation: u64, + revision: Option, + id: String, + vcs: Option, +) -> Result<(), String> { + super::live(&app, caller.label())?; + super::validate(&scope)?; + if id.len() > 32768 + || vcs.as_ref().is_some_and(|vcs| vcs.len() > 32) + || revision.as_ref().is_some_and(|revision| { + revision.len() != 64 || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + { + return Err("invalidRequest".into()); + } + let host = caller.label().to_string(); + let deadline = std::time::Instant::now() + registry::READ_TIMEOUT; + let permit = tokio::time::timeout( + registry::READ_TIMEOUT, + app.state::() + .reads + .clone() + .acquire_owned(), + ) + .await + .map_err(|_| "requestTimeout")? + .map_err(|_| registry::STALE)?; + tokio::time::timeout_at( + tokio::time::Instant::from_std(deadline), + tauri::async_runtime::spawn_blocking(move || { + let _permit = permit; + let state = app.state::(); + let (snapshot, expected, registration) = state + .registry + .lock() + .map_err(|_| "internalError")? + .capture(&host, &scope, generation)?; + let mapping = snapshot.mappings.as_ref().ok_or(registry::STALE)?; + if mapping.revision != revision { + return Err("vcs.mapping.conflict".into()); + } + let (packages, _leases) = super::packages(&app)?; + if packages != expected { + return Err(registry::STALE.into()); + } + let current = super::redetect(&app, &scope, &packages, &snapshot)?; + if current.truncated || !registry::same_roots(&snapshot.detection, ¤t) { + return Err(registry::STALE.into()); + } + let unmapped = registry::detect(&Core, &scope, &packages)?; + let candidates = unmapped.ambiguous.iter().find(|row| row["id"] == id); + if candidates.is_none() + && (vcs.is_some() || !mapping.entries.iter().any(|entry| entry.id == id)) + { + return Err("invalidRequest".into()); + } + if vcs.as_ref().is_some_and(|vcs| { + !candidates + .map(|candidate| &candidate["vcs"]) + .and_then(Value::as_array) + .is_some_and(|all| all.iter().any(|value| value == vcs)) + }) { + return Err("invalidRequest".into()); + } + save_at( + Path::new(&mapping.source), + Path::new(&scope.workspace_root), + mapping, + &id, + vcs.as_deref(), + || { + if std::time::Instant::now() >= deadline { + return Err("requestTimeout".into()); + } + super::live(&app, &host)?; + if source(&app, &scope)?.to_string_lossy() != mapping.source { + return Err(registry::STALE.into()); + } + if super::project_registration(&app, &host, &scope)? != registration { + return Err(registry::STALE.into()); + } + state + .registry + .lock() + .map_err(|_| "internalError")? + .capture(&host, &scope, generation)?; + Ok(()) + }, + )?; + super::metadata_changed(&app, &host, &scope, generation); + Ok(()) + }), + ) + .await + .map_err(|_| "requestTimeout")? + .map_err(|_| "internalError")? +} + +#[cfg(test)] +#[path = "vcs_mappings_tests.rs"] +mod tests; diff --git a/windows/tauri/src-tauri/src/plugin_host/vcs_mappings_tests.rs b/windows/tauri/src-tauri/src/plugin_host/vcs_mappings_tests.rs new file mode 100644 index 0000000000..403b4d1b7f --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/vcs_mappings_tests.rs @@ -0,0 +1,407 @@ +use super::super::registry::{detect_mapped, owners, Package, Snapshot}; +use super::*; +use std::sync::atomic::{AtomicU64, Ordering}; +static NEXT: AtomicU64 = AtomicU64::new(1); +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "lithe-vcs-mappings-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + for marker in ["mixed/.git", "mixed/.svn", "mixed/nested/.git", "project"] { + std::fs::create_dir_all(path.join(marker)).unwrap(); + } + Self(path) + } + fn source(&self) -> PathBuf { + self.0.join("state/choices.json") + } + fn scope(&self) -> Scope { + Scope { + workspace_id: "fixture".into(), + workspace_root: self.0.to_string_lossy().into_owned(), + incarnation: "one".into(), + } + } +} +impl Drop for Fixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).expect("remove mapping fixture"); + } +} +fn packages() -> Vec { + let manifest: Value = serde_json::from_str(include_str!( + "../../../../../shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json" + )) + .unwrap(); + vec![Package { + plugin_id: manifest["id"].as_str().unwrap().into(), + identity: "approved-package".into(), + manifest, + }] +} + +#[test] +fn opening_is_read_only_and_confirmed_mapping_reopens_with_real_core_ownership() { + let fixture = Fixture::new(); + let first = read_at(&fixture.source(), &fixture.0).unwrap(); + assert!(first.entries.is_empty()); + assert!(!fixture.source().parent().unwrap().exists()); + let saved = save_at( + &fixture.source(), + &fixture.0, + &first, + "mixed", + Some("svn"), + || Ok(()), + ) + .unwrap(); + let reopened = read_at(&fixture.source(), &fixture.0).unwrap(); + assert_eq!(saved, reopened); + let scope = fixture.scope(); + let evidence: Value = serde_json::from_str(include_str!( + "../../../../../shared/fixtures/vcs/windows-root-mappings-v1.json" + )) + .unwrap(); + assert_eq!(evidence["rules"]["maxMappings"], MAX_MAPPINGS); + assert_eq!(evidence["rules"]["maxSourceBytes"], MAX_BYTES); + assert_eq!(evidence["mapping"], json!(saved.entries[0])); + let detection = detect_mapped(&Core, &scope, &packages(), &reopened.entries).unwrap(); + assert!(detection.ambiguous.is_empty()); + assert!(detection + .roots + .iter() + .any(|root| root.id == "mixed" && root.vcs == "svn" && root.mapped)); + let snapshot = Snapshot { + generation: 1, + workspace_id: scope.workspace_id, + workspace_root: scope.workspace_root, + detection, + mappings: Some(reopened), + watch_error: None, + mapping_choices: Vec::new(), + }; + let paths: Vec = serde_json::from_value(evidence["paths"].clone()).unwrap(); + let ownership = owners(&Core, &snapshot, &paths).unwrap(); + assert_eq!(ownership.owners[0].root.as_deref(), Some("mixed")); + assert_eq!(ownership.owners[1].root.as_deref(), Some("mixed/nested")); + assert_eq!( + json!(ownership + .owners + .iter() + .map(|owner| &owner.root) + .collect::>()), + evidence["owners"] + ); + assert!(ownership.common_root.is_none()); + assert_eq!( + detect_mapped(&Core, &fixture.scope(), &[], &saved.entries) + .unwrap() + .unused_mappings + .len(), + 1 + ); +} + +#[test] +fn saves_preserve_unknown_data_permissions_and_reject_external_edits() { + let fixture = Fixture::new(); + std::fs::create_dir_all(fixture.source().parent().unwrap()).unwrap(); + let identity = directory_identity(&fixture.0.join("mixed")).unwrap(); + std::fs::write(fixture.source(), json!({"version":1,"future":{"keep":true},"mappings":[{"id":"mixed","vcs":"git","directoryIdentity":identity,"future":"preserve"}]}).to_string()).unwrap(); + let old = read_at(&fixture.source(), &fixture.0).unwrap(); + let permissions = std::fs::metadata(fixture.source()).unwrap().permissions(); + let saved = save_at( + &fixture.source(), + &fixture.0, + &old, + "mixed", + Some("svn"), + || Ok(()), + ) + .unwrap(); + let bytes = std::fs::read(fixture.source()).unwrap(); + let value: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(value["future"]["keep"], true); + assert_eq!(value["mappings"][0]["future"], "preserve"); + assert_eq!( + std::fs::metadata(fixture.source()) + .unwrap() + .permissions() + .readonly(), + permissions.readonly() + ); + std::fs::write(fixture.source(), b"{\"version\":1,\"external\":true}").unwrap(); + assert_eq!( + save_at(&fixture.source(), &fixture.0, &saved, "mixed", None, || Ok( + () + )) + .unwrap_err(), + "vcs.mapping.conflict" + ); + assert_eq!( + std::fs::read(fixture.source()).unwrap(), + b"{\"version\":1,\"external\":true}" + ); +} + +#[test] +fn a_replaced_directory_never_inherits_a_saved_choice_and_stale_commit_cleans_its_temp() { + let fixture = Fixture::new(); + let first = read_at(&fixture.source(), &fixture.0).unwrap(); + let saved = save_at( + &fixture.source(), + &fixture.0, + &first, + "mixed", + Some("svn"), + || Ok(()), + ) + .unwrap(); + std::fs::rename(fixture.0.join("mixed"), fixture.0.join("previous")).unwrap(); + std::fs::create_dir(fixture.0.join("mixed")).unwrap(); + assert!(read_at(&fixture.source(), &fixture.0) + .unwrap() + .entries + .is_empty()); + assert_eq!( + save_at( + &fixture.source(), + &fixture.0, + &saved, + "mixed", + Some("git"), + || Ok(()) + ) + .unwrap_err(), + "vcs.mapping.conflict" + ); + let current = read_at(&fixture.source(), &fixture.0).unwrap(); + let before = std::fs::read(fixture.source()).unwrap(); + assert_eq!( + save_at( + &fixture.source(), + &fixture.0, + ¤t, + "mixed", + Some("git"), + || Err(registry::STALE.into()) + ) + .unwrap_err(), + registry::STALE + ); + assert_eq!(std::fs::read(fixture.source()).unwrap(), before); + assert_eq!( + std::fs::read_dir(fixture.source().parent().unwrap()) + .unwrap() + .count(), + 1 + ); +} + +#[test] +fn mappings_follow_enclosing_core_ids_and_reject_path_aliases_and_oversized_sources() { + let fixture = Fixture::new(); + std::fs::create_dir_all(fixture.0.join(".git")).unwrap(); + std::fs::create_dir_all(fixture.0.join(".svn")).unwrap(); + let workspace = fixture.0.join("project"); + let initial = read_at(&fixture.source(), &workspace).unwrap(); + let saved = save_at( + &fixture.source(), + &workspace, + &initial, + "..", + Some("svn"), + || Ok(()), + ) + .unwrap(); + let mut scope = fixture.scope(); + scope.workspace_root = workspace.to_string_lossy().into_owned(); + let roots = detect_mapped(&Core, &scope, &packages(), &saved.entries).unwrap(); + assert!(roots + .roots + .iter() + .any(|root| root.id == ".." && root.vcs == "svn" && root.mapped)); + for invalid in [ + "../mixed", + "a/../mixed", + "/mixed", + "a//b", + ".", + "a\\b", + "C:alias", + ] { + assert!(root_path(&workspace, invalid).is_err(), "{invalid}"); + } + std::fs::write(fixture.source(), vec![b' '; MAX_BYTES as usize + 1]).unwrap(); + assert!(read_at(&fixture.source(), &workspace).is_err()); +} + +#[test] +fn unchanged_missing_choice_does_not_create_storage_and_readonly_never_replaces_bytes() { + let fixture = Fixture::new(); + let initial = read_at(&fixture.source(), &fixture.0).unwrap(); + save_at( + &fixture.source(), + &fixture.0, + &initial, + "mixed", + None, + || Ok(()), + ) + .unwrap(); + assert!(!fixture.source().exists()); + let saved = save_at( + &fixture.source(), + &fixture.0, + &initial, + "mixed", + Some("svn"), + || Ok(()), + ) + .unwrap(); + let before = std::fs::read(fixture.source()).unwrap(); + let mut permissions = std::fs::metadata(fixture.source()).unwrap().permissions(); + let original = permissions.clone(); + permissions.set_readonly(true); + std::fs::set_permissions(fixture.source(), permissions).unwrap(); + // Restore before asserting so an assertion failure never strands a Windows + // read-only temporary file that the fixture cannot remove. + let result = save_at( + &fixture.source(), + &fixture.0, + &saved, + "mixed", + Some("git"), + || Ok(()), + ); + std::fs::set_permissions(fixture.source(), original).unwrap(); + assert_eq!(result.unwrap_err(), "vcs.mapping.readOnly"); + assert_eq!(std::fs::read(fixture.source()).unwrap(), before); +} + +#[test] +fn future_rows_with_the_same_id_are_never_modified_or_committed_as_duplicate_choices() { + let fixture = Fixture::new(); + std::fs::create_dir_all(fixture.source().parent().unwrap()).unwrap(); + let future = json!({"version":1,"mappings":[{"id":"mixed","futureFormat":{"keep":true}}]}); + std::fs::write(fixture.source(), future.to_string()).unwrap(); + let snapshot = read_at(&fixture.source(), &fixture.0).unwrap(); + let before = std::fs::read(fixture.source()).unwrap(); + assert_eq!( + save_at( + &fixture.source(), + &fixture.0, + &snapshot, + "mixed", + Some("svn"), + || Ok(()) + ) + .unwrap_err(), + "vcs.mapping.readOnly" + ); + save_at( + &fixture.source(), + &fixture.0, + &snapshot, + "mixed", + None, + || Ok(()), + ) + .unwrap(); + assert_eq!(std::fs::read(fixture.source()).unwrap(), before); + let identity = directory_identity(&fixture.0.join("mixed")).unwrap(); + let duplicate = json!({"version":1,"mappings":[future["mappings"][0],{"id":"mixed","vcs":"svn","directoryIdentity":identity}]}); + std::fs::write(fixture.source(), duplicate.to_string()).unwrap(); + assert!(read_at(&fixture.source(), &fixture.0).is_err()); + assert_eq!( + serde_json::from_slice::(&std::fs::read(fixture.source()).unwrap()).unwrap(), + duplicate + ); +} + +#[test] +fn a_choice_whose_marker_disappeared_can_be_explicitly_cleared() { + let fixture = Fixture::new(); + let initial = read_at(&fixture.source(), &fixture.0).unwrap(); + let saved = save_at( + &fixture.source(), + &fixture.0, + &initial, + "mixed", + Some("svn"), + || Ok(()), + ) + .unwrap(); + std::fs::remove_dir(fixture.0.join("mixed/.svn")).unwrap(); + assert_eq!( + detect_mapped(&Core, &fixture.scope(), &packages(), &saved.entries) + .unwrap() + .unused_mappings + .len(), + 1 + ); + let cleared = save_at(&fixture.source(), &fixture.0, &saved, "mixed", None, || { + Ok(()) + }) + .unwrap(); + assert!(cleared.entries.is_empty()); + assert!( + detect_mapped(&Core, &fixture.scope(), &packages(), &cleared.entries) + .unwrap() + .unused_mappings + .is_empty() + ); +} + +#[test] +fn an_external_edit_during_final_scope_validation_is_not_overwritten() { + let fixture = Fixture::new(); + let initial = read_at(&fixture.source(), &fixture.0).unwrap(); + let saved = save_at( + &fixture.source(), + &fixture.0, + &initial, + "mixed", + Some("svn"), + || Ok(()), + ) + .unwrap(); + let external = b"{\"version\":1,\"external\":true}"; + let result = save_at( + &fixture.source(), + &fixture.0, + &saved, + "mixed", + Some("git"), + || { + std::fs::write(fixture.source(), external).unwrap(); + Ok(()) + }, + ); + assert_eq!(result.unwrap_err(), "vcs.mapping.conflict"); + assert_eq!(std::fs::read(fixture.source()).unwrap(), external); + assert_eq!( + std::fs::read_dir(fixture.source().parent().unwrap()) + .unwrap() + .count(), + 1 + ); +} + +#[test] +fn storage_preflight_rejection_creates_no_directories_or_temporary_files() { + let fixture = Fixture::new(); + let result = super::super::super::settings::write_object_snapshot( + &fixture.source(), + &Map::new(), + &None, + MAX_BYTES, + || Err("vcs.mapping.readOnly".into()), + || panic!("final validation cannot run after rejected storage preflight"), + ); + assert_eq!(result.unwrap_err(), "vcs.mapping.readOnly"); + assert!(!fixture.source().parent().unwrap().exists()); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/vcs_query.rs b/windows/tauri/src-tauri/src/plugin_host/vcs_query.rs new file mode 100644 index 0000000000..b34e6d7397 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/vcs_query.rs @@ -0,0 +1,279 @@ +//! Read-only operation selection and workspace projection. Provider paths are +//! data, never arbitrary native read targets; Core remains the ownership judge. +use super::{registry, session, status}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +pub(super) const MAX_PAGE: usize = 100; +const MAX_CONTENT: u64 = 32 * 1024 * 1024; + +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "operation", rename_all = "camelCase", deny_unknown_fields)] +pub enum ReadOperation { + Status, + History { + cursor: Option, + limit: usize, + }, + FileHistory { + path: String, + cursor: Option, + limit: usize, + }, + BaseContent { + path: String, + #[serde(rename = "currentPath")] + current_path: String, + }, +} +impl ReadOperation { + pub(super) fn name(&self) -> &'static str { + match self { + Self::Status => "status", + Self::History { .. } => "history", + Self::FileHistory { .. } => "fileHistory", + Self::BaseContent { .. } => "baseContent", + } + } + pub(super) fn arguments(&self) -> Value { + match self { + Self::Status => json!({"includeIgnored":false}), + Self::History { cursor, limit } => json!({"cursor":cursor,"limit":limit}), + Self::FileHistory { + path, + cursor, + limit, + } => json!({"path":path,"cursor":cursor,"limit":limit}), + Self::BaseContent { path, .. } => json!({"path":path}), + } + } + pub(super) fn validate(&self) -> Result<(), String> { + match self { + Self::History { cursor, limit } | Self::FileHistory { cursor, limit, .. } => { + if !(1..=MAX_PAGE).contains(limit) + || cursor.as_ref().is_some_and(|cursor| { + cursor.is_empty() + || cursor.len() > 256 + || cursor.chars().any(char::is_control) + }) + { + return Err("invalidRequest".into()); + } + } + _ => {} + } + for path in self.paths() { + validate_path(path)?; + } + Ok(()) + } + fn paths(&self) -> Vec<&str> { + match self { + Self::FileHistory { path, .. } => vec![path], + Self::BaseContent { path, current_path } => vec![path, current_path], + _ => Vec::new(), + } + } +} +pub(super) fn validate_path(path: &str) -> Result<(), String> { + if path.is_empty() + || path.len() > 32768 + || path.contains(['\\', ':', '\0']) + || path + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + { + return Err("invalidRequest".into()); + } + Ok(()) +} +fn reparse(metadata: &std::fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + metadata.file_type().is_symlink() || metadata.file_attributes() & 0x400 != 0 + } + #[cfg(not(windows))] + { + metadata.file_type().is_symlink() + } +} +pub(super) fn ordinary_chain(workspace: &Path, path: &Path) -> Result<(), String> { + let relative = path + .strip_prefix(workspace) + .map_err(|_| "vcs.provider.pathUnavailable")?; + let mut current = workspace.to_path_buf(); + for part in relative.components() { + current.push(part); + match std::fs::symlink_metadata(¤t) { + Ok(metadata) if reparse(&metadata) => return Err("vcs.provider.pathUnavailable".into()), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, + Err(_) => return Err("vcs.provider.pathUnavailable".into()), + } + } + Ok(()) +} +pub(super) fn paths( + core: &dyn registry::VcsCore, + snapshot: ®istry::Snapshot, + root: ®istry::Root, + operation: &ReadOperation, +) -> Result, String> { + operation.validate()?; + if snapshot.detection.truncated { + return Err("vcs.provider.incompleteRoots".into()); + } + let workspace = Path::new(&snapshot.workspace_root) + .canonicalize() + .map_err(|_| registry::STALE)?; + let mut targets = Vec::new(); + let mut relative = Vec::new(); + for path in operation.paths() { + let target = Path::new(&root.path).join(path); + let in_workspace = target + .strip_prefix(&workspace) + .map_err(|_| "vcs.provider.pathUnavailable")?; + ordinary_chain(&workspace, &target)?; + relative.push( + in_workspace + .to_str() + .ok_or("invalidRequest")? + .replace('\\', "/"), + ); + targets.push(target); + } + if !relative.is_empty() { + let owners = registry::owners(core, snapshot, &relative)?; + if !owners.complete + || owners + .owners + .iter() + .any(|owner| owner.root.as_deref() != Some(root.id.as_str())) + { + return Err("vcs.provider.pathUnavailable".into()); + } + } + Ok(targets) +} + +fn current_content(workspace: &Path, target: &Path) -> session::Reply { + ordinary_chain(workspace, target)?; + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = match options.open(target) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(json!({"currentContent":null,"currentBinary":false})) + } + Err(_) => return Err("vcs.provider.pathUnavailable".into()), + }; + let metadata = file + .metadata() + .map_err(|_| "vcs.provider.pathUnavailable")?; + if !metadata.is_file() || reparse(&metadata) || metadata.len() > MAX_CONTENT { + return Err("vcs.provider.contentUnavailable".into()); + } + let identity = same_file::Handle::from_file( + file.try_clone() + .map_err(|_| "vcs.provider.pathUnavailable")?, + ) + .map_err(|_| "vcs.provider.pathUnavailable")?; + let mut bytes = Vec::new(); + file.by_ref() + .take(MAX_CONTENT + 1) + .read_to_end(&mut bytes) + .map_err(|_| "vcs.provider.contentUnavailable")?; + ordinary_chain(workspace, target)?; + let canonical = target + .canonicalize() + .map_err(|_| "vcs.provider.pathUnavailable")?; + if !canonical.starts_with(workspace) + || bytes.len() as u64 > MAX_CONTENT + || identity + != same_file::Handle::from_path(target).map_err(|_| "vcs.provider.pathUnavailable")? + { + return Err("vcs.provider.pathUnavailable".into()); + } + match String::from_utf8(bytes) { + Ok(text) if !text.contains('\0') => { + Ok(json!({"currentContent":text,"currentBinary":false})) + } + _ => Ok(json!({"currentContent":null,"currentBinary":true})), + } +} + +pub(super) fn project( + core: &dyn registry::VcsCore, + snapshot: ®istry::Snapshot, + root: ®istry::Root, + operation: &ReadOperation, + mut result: Value, +) -> session::Reply { + let targets = paths(core, snapshot, root, operation)?; + match operation { + ReadOperation::Status => status::project_status(core, snapshot, root, result), + ReadOperation::History { .. } | ReadOperation::FileHistory { .. } => { + for entry in result["entries"] + .as_array_mut() + .ok_or("plugin.vcs.invalidOutput")? + { + let count = entry["changes"] + .as_array() + .ok_or("plugin.vcs.invalidOutput")? + .len(); + let projected = status::project_status( + core, + snapshot, + root, + json!({"changes":entry["changes"]}), + )?; + entry["changes"] = projected["changes"].clone(); + if entry["changes"].as_array().unwrap().len() != count { + entry["changesTruncated"] = json!(true); + } + } + Ok(result) + } + ReadOperation::BaseContent { path, current_path } => { + let workspace = Path::new(&snapshot.workspace_root) + .canonicalize() + .map_err(|_| registry::STALE)?; + let current = current_content(&workspace, &targets[1])?; + result["currentContent"] = current["currentContent"].clone(); + result["currentBinary"] = current["currentBinary"].clone(); + result["path"] = json!(path); + result["currentPath"] = json!(current_path); + Ok(result) + } + } +} + +#[tauri::command] +pub async fn declarative_plugins_vcs_query( + app: tauri::AppHandle, + caller: tauri::WebviewWindow, + scope: super::Scope, + generation: u64, + root_id: String, + request_id: String, + query: ReadOperation, +) -> Result { + // This endpoint accepts only the typed read operations above. In particular + // it is not a generic lsp.vcsRequest or a provider method/command tunnel. + if matches!(query, ReadOperation::Status) { + return Err("invalidRequest".into()); + } + status::read(app, caller, scope, generation, root_id, request_id, query).await +} + +#[cfg(test)] +#[path = "vcs_query_tests.rs"] +mod tests; diff --git a/windows/tauri/src-tauri/src/plugin_host/vcs_query_tests.rs b/windows/tauri/src-tauri/src/plugin_host/vcs_query_tests.rs new file mode 100644 index 0000000000..0601afda09 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/vcs_query_tests.rs @@ -0,0 +1,374 @@ +use super::*; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1); + +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let fixture = Self(std::env::temp_dir().join(format!( + "lithe-vcs-query-{}-{}", + std::process::id(), + NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed) + ))); + std::fs::create_dir_all(fixture.0.join(".svn")).unwrap(); + fixture + } + fn snapshot(&self, workspace: &Path) -> registry::Snapshot { + let manifest: Value = serde_json::from_str(include_str!( + "../../../../../shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json" + )) + .unwrap(); + let scope = super::super::Scope { + workspace_id: "fixture".into(), + workspace_root: workspace.to_string_lossy().into_owned(), + incarnation: "one".into(), + }; + let packages = [registry::Package { + plugin_id: manifest["id"].as_str().unwrap().into(), + identity: "whole-package-digest".into(), + manifest, + }]; + registry::Snapshot { + generation: 1, + mappings: None, + watch_error: None, + mapping_choices: Vec::new(), + workspace_id: scope.workspace_id.clone(), + workspace_root: scope.workspace_root.clone(), + detection: registry::detect(&super::super::Core, &scope, &packages).unwrap(), + } + } +} +impl Drop for Fixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).expect("remove VCS query fixture"); + } +} + +fn decoded(operation: &ReadOperation, reply: Value) -> Value { + let response = crate::plugin_host::scan::core_execute( + "vcs.decodeReply", + json!({"operation":operation.name(),"capabilities":{ + "status":1,"baseContent":1,"checkin":1,"rollback":1,"history":1,"fileHistory":1 + },"request":operation.arguments(),"reply":reply}), + ) + .unwrap(); + assert_eq!(response["status"], "ok"); + response["result"].clone() +} + +#[test] +fn history_query_rejects_unbounded_pages_and_repeated_or_control_cursors() { + assert!(ReadOperation::History { + cursor: None, + limit: 0 + } + .validate() + .is_err()); + assert!(ReadOperation::History { + cursor: None, + limit: MAX_PAGE + 1 + } + .validate() + .is_err()); + assert!(ReadOperation::History { + cursor: Some(String::new()), + limit: 1 + } + .validate() + .is_err()); + assert!(ReadOperation::History { + cursor: Some("next\npage".into()), + limit: 1 + } + .validate() + .is_err()); + assert!(ReadOperation::History { + cursor: Some("opaque-provider-cursor".into()), + limit: 1 + } + .validate() + .is_ok()); +} + +#[test] +fn file_queries_reject_absolute_parent_and_windows_separator_paths() { + for path in [ + "C:/outside.txt", + "/outside.txt", + "../outside.txt", + "folder/../file.txt", + "folder\\file.txt", + "folder//file.txt", + ] { + assert!( + ReadOperation::FileHistory { + path: path.into(), + cursor: None, + limit: 1 + } + .validate() + .is_err(), + "{path}" + ); + } + assert!(ReadOperation::FileHistory { + path: "src/Main.java".into(), + cursor: None, + limit: 50 + } + .validate() + .is_ok()); +} + +#[test] +fn baseline_query_keeps_history_path_and_current_path_distinct() { + let query = ReadOperation::BaseContent { + path: "deleted.txt".into(), + current_path: "renamed.txt".into(), + }; + assert_eq!(query.name(), "baseContent"); + assert_eq!(query.arguments()["path"], "deleted.txt"); + assert!(query.validate().is_ok()); +} + +#[test] +fn current_content_is_bounded_and_reports_missing_or_binary_without_exposing_bytes() { + let fixture = Fixture::new(); + // project() supplies canonical workspace/root paths. On Windows a plain + // fixture path has a different prefix kind from canonicalize()'s result. + let root = fixture.0.canonicalize().unwrap(); + let text = root.join("text.txt"); + let binary = root.join("binary.dat"); + std::fs::write(&text, "base\n").unwrap(); + std::fs::write(&binary, [0_u8, 1, 2, 3]).unwrap(); + let text_result = current_content(&root, &text).unwrap(); + assert_eq!(text_result["currentContent"], "base\n"); + assert_eq!(text_result["currentBinary"], false); + let binary_result = current_content(&root, &binary).unwrap(); + assert_eq!(binary_result["currentContent"], Value::Null); + assert_eq!(binary_result["currentBinary"], true); + let missing = current_content(&root, &root.join("deleted.txt")).unwrap(); + assert_eq!(missing["currentContent"], Value::Null); + assert_eq!(missing["currentBinary"], false); + let oversized = root.join("oversized.txt"); + std::fs::File::create(&oversized) + .unwrap() + .set_len(MAX_CONTENT + 1) + .unwrap(); + assert_eq!( + current_content(&root, &oversized).unwrap_err(), + "vcs.provider.contentUnavailable" + ); +} + +#[test] +fn production_projection_accepts_deleted_and_missing_paths_from_an_ordinary_workspace() { + let fixture = Fixture::new(); + std::fs::write(fixture.0.join("deleted.txt"), "removed\n").unwrap(); + std::fs::remove_file(fixture.0.join("deleted.txt")).unwrap(); + // The real entry point receives the ordinary workspace string, resolves + // its canonical identity itself, and keeps nonexistent paths lexical. + let snapshot = fixture.snapshot(&fixture.0); + let root = snapshot + .detection + .roots + .iter() + .find(|root| root.id.is_empty()) + .unwrap(); + for path in ["deleted.txt", "missing.txt", "missing-directory/file.txt"] { + let operation = ReadOperation::BaseContent { + path: path.into(), + current_path: path.into(), + }; + let result = project( + &super::super::Core, + &snapshot, + root, + &operation, + decoded(&operation, json!({"content":"base\n","binary":false})), + ) + .unwrap(); + assert_eq!(result["content"], "base\n"); + assert_eq!(result["currentContent"], Value::Null); + assert_eq!(result["currentBinary"], false); + } +} + +fn link_directory(target: &Path, link: &Path) { + #[cfg(windows)] + { + // Reuse the build/web fixture's junction boundary without requiring + // Developer Mode. The native runner owns the bounded process tree. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let mut command = std::process::Command::new("cmd"); + command + .args(["/D", "/C", "mklink", "/J"]) + .arg(link) + .arg(target); + let outcome = lithe_git_host::run( + &mut command, + None, + || std::time::Instant::now() >= deadline, + || {}, + |_, _| {}, + ); + assert!( + outcome.failure.is_none() && outcome.status.is_some_and(|status| status.success()), + "junction creation failed: {}", + String::from_utf8_lossy(&outcome.stderr) + ); + } + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).expect("directory symlink"); +} + +#[test] +fn current_and_missing_content_below_a_junction_escape_are_both_rejected() { + let fixture = Fixture::new(); + let outside = Fixture::new(); + std::fs::write(outside.0.join("private.txt"), "outside\n").unwrap(); + link_directory(&outside.0, &fixture.0.join("escape")); + let workspace = fixture.0.canonicalize().unwrap(); + let snapshot = fixture.snapshot(&fixture.0); + let root = snapshot + .detection + .roots + .iter() + .find(|root| root.id.is_empty()) + .unwrap(); + for path in ["escape/private.txt", "escape/missing.txt"] { + assert_eq!( + current_content(&workspace, &workspace.join(path)).unwrap_err(), + "vcs.provider.pathUnavailable" + ); + let operation = ReadOperation::BaseContent { + path: path.into(), + current_path: path.into(), + }; + assert_eq!( + project( + &super::super::Core, + &snapshot, + root, + &operation, + decoded(&operation, json!({"content":null,"binary":false})), + ) + .unwrap_err(), + "vcs.provider.pathUnavailable" + ); + } + drop(fixture); + assert_eq!( + std::fs::read(outside.0.join("private.txt")).unwrap(), + b"outside\n" + ); +} + +#[test] +fn real_core_history_projection_keeps_provider_order_cursor_and_workspace_ownership() { + let fixture = Fixture::new(); + std::fs::create_dir_all(fixture.0.join("child/nested/.git")).unwrap(); + let snapshot = fixture.snapshot(&fixture.0.join("child")); + let root = snapshot + .detection + .roots + .iter() + .find(|root| root.id == "..") + .unwrap(); + for operation in [ + ReadOperation::History { + cursor: Some("previous".into()), + limit: 2, + }, + ReadOperation::FileHistory { + path: "child/deleted.txt".into(), + cursor: None, + limit: 2, + }, + ] { + let result = project( + &super::super::Core, + &snapshot, + root, + &operation, + decoded( + &operation, + json!({"entries":[ + {"revision":{"id":"9","kind":"number"},"message":"new","changes":[ + {"path":"child/deleted.txt","status":"deleted"}, + {"path":"outside.txt","status":"modified"}, + {"path":"child/nested/git.txt","status":"modified"}, + {"path":"child/moved.txt","oldPath":"outside.txt","status":"renamed"} + ]}, + {"revision":{"id":"3","kind":"number"},"message":"old","changes":[]} + ],"nextCursor":"opaque-next"}), + ), + ) + .unwrap(); + assert_eq!(result["entries"][0]["revision"]["id"], "9"); + assert_eq!(result["entries"][1]["revision"]["id"], "3"); + assert_eq!(result["nextCursor"], "opaque-next"); + assert_eq!(result["entries"][0]["changes"].as_array().unwrap().len(), 1); + assert_eq!( + result["entries"][0]["changes"][0]["path"], + "child/deleted.txt" + ); + assert_eq!(result["entries"][0]["changesTruncated"], true); + } +} + +#[test] +fn real_core_baseline_projection_keeps_deleted_and_renamed_paths_and_denies_other_roots() { + let fixture = Fixture::new(); + std::fs::create_dir_all(fixture.0.join("nested/.git")).unwrap(); + std::fs::write(fixture.0.join("renamed.txt"), "current\n").unwrap(); + let snapshot = fixture.snapshot(&fixture.0); + let root = snapshot + .detection + .roots + .iter() + .find(|root| root.id.is_empty()) + .unwrap(); + let renamed = ReadOperation::BaseContent { + path: "before.txt".into(), + current_path: "renamed.txt".into(), + }; + let result = project( + &super::super::Core, + &snapshot, + root, + &renamed, + decoded(&renamed, json!({"content":"base\n","binary":false})), + ) + .unwrap(); + assert_eq!(result["path"], "before.txt"); + assert_eq!(result["currentPath"], "renamed.txt"); + assert_eq!(result["content"], "base\n"); + assert_eq!(result["currentContent"], "current\n"); + let deleted = ReadOperation::BaseContent { + path: "deleted.txt".into(), + current_path: "deleted.txt".into(), + }; + let result = project( + &super::super::Core, + &snapshot, + root, + &deleted, + decoded(&deleted, json!({"content":"old\n","binary":false})), + ) + .unwrap(); + assert_eq!(result["currentContent"], Value::Null); + assert_eq!(result["currentBinary"], false); + assert!(paths( + &super::super::Core, + &snapshot, + root, + &ReadOperation::BaseContent { + path: "before.txt".into(), + current_path: "nested/git.txt".into() + } + ) + .is_err()); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/vcs_registry.rs b/windows/tauri/src-tauri/src/plugin_host/vcs_registry.rs new file mode 100644 index 0000000000..25b06f2d65 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/vcs_registry.rs @@ -0,0 +1,380 @@ +//! Read-only VCS snapshots bound to a native window and workspace incarnation. +//! Core owns markers, provider precedence, ambiguity and deepest-root semantics. +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant}; + +pub(super) const READ_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_WORKSPACES: usize = 128; +const MAX_RETIRED: usize = 4096; +pub(super) const STALE: &str = "requestCancelled"; + +pub(super) trait VcsCore { + fn execute(&self, command: &str, payload: Value) -> Result; +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Package { + pub plugin_id: String, + pub identity: String, + pub manifest: Value, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Scope { + pub workspace_id: String, + pub workspace_root: String, + pub incarnation: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct Root { + pub id: String, + pub vcs: String, + pub path: String, + pub mapped: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Detection { + pub providers: Vec, + pub roots: Vec, + pub ambiguous: Vec, + pub unused_mappings: Vec, + pub truncated: bool, + pub rejected: Vec, + pub conflicts: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Snapshot { + pub generation: u64, + pub workspace_id: String, + pub workspace_root: String, + pub detection: Detection, + #[serde(skip_serializing_if = "Option::is_none")] + pub mappings: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub watch_error: Option, + pub mapping_choices: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct Owner { + pub path: String, + pub root: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Ownership { + pub generation: u64, + pub owners: Vec, + /// A batch/move can target one owner only when every path resolved to it + /// and the detector did not report a partial walk. This is no execution grant. + pub common_root: Option, + pub complete: bool, +} + +#[derive(Clone)] +pub(super) struct Ticket { + host: String, + scope: Scope, + pub generation: u64, + pub deadline: Instant, + pub registration: u64, + started: Instant, +} +struct Entry { + ticket: Ticket, + snapshot: Option<(Snapshot, Vec)>, + // Survives process cleanup until the next explicit scan replaces this + // entry. Delayed events from our own write cannot revoke its reply. + awaiting_write_refresh: bool, +} +#[derive(Default)] +pub(super) struct Registry { + next: u64, + entries: HashMap<(String, String), Entry>, + retired: HashSet<(String, String, String)>, + exhausted: bool, +} +impl Registry { + pub fn begin( + &mut self, + host: &str, + scope: Scope, + registration: u64, + now: Instant, + ) -> Result { + let key = (host.into(), scope.workspace_id.clone()); + if self.exhausted + || self.retired.contains(&( + host.into(), + scope.workspace_id.clone(), + scope.incarnation.clone(), + )) + { + return Err(STALE.into()); + } + if !self.entries.contains_key(&key) && self.entries.len() >= MAX_WORKSPACES { + return Err("requestLimitExceeded".into()); + } + if let Some(previous) = self.entries.get(&key) { + if previous.ticket.started > now { + return Err(STALE.into()); + } + if previous.ticket.scope != scope || previous.ticket.registration != registration { + if previous.ticket.scope.incarnation == scope.incarnation { + return Err(STALE.into()); + } + let previous = previous.ticket.scope.clone(); + self.retire(host, &previous)?; + } + } + self.next = self.next.checked_add(1).ok_or("requestLimitExceeded")?; + let ticket = Ticket { + host: host.into(), + scope, + generation: self.next, + deadline: now + READ_TIMEOUT, + registration, + started: now, + }; + self.entries.insert( + key, + Entry { + ticket: ticket.clone(), + snapshot: None, + awaiting_write_refresh: false, + }, + ); + Ok(ticket) + } + fn entry(&self, ticket: &Ticket) -> Result<&Entry, String> { + self.entries + .get(&(ticket.host.clone(), ticket.scope.workspace_id.clone())) + .filter(|entry| { + entry.ticket.scope == ticket.scope && entry.ticket.generation == ticket.generation + }) + .ok_or_else(|| STALE.into()) + } + pub fn publish( + &mut self, + ticket: &Ticket, + detection: Detection, + packages: Vec, + now: Instant, + ) -> Result { + self.entry(ticket)?; + if now >= ticket.deadline { + return Err("requestTimeout".into()); + } + let snapshot = Snapshot { + generation: ticket.generation, + workspace_id: ticket.scope.workspace_id.clone(), + workspace_root: ticket.scope.workspace_root.clone(), + detection, + mappings: None, + watch_error: None, + mapping_choices: Vec::new(), + }; + self.entries + .get_mut(&(ticket.host.clone(), ticket.scope.workspace_id.clone())) + .expect("checked entry") + .snapshot = Some((snapshot.clone(), packages)); + Ok(snapshot) + } + pub fn capture( + &self, + host: &str, + scope: &Scope, + generation: u64, + ) -> Result<(Snapshot, Vec, u64), String> { + let entry = self + .entries + .get(&(host.into(), scope.workspace_id.clone())) + .filter(|entry| entry.ticket.scope == *scope && entry.ticket.generation == generation) + .ok_or(STALE)?; + entry + .snapshot + .clone() + .map(|(snapshot, packages)| (snapshot, packages, entry.ticket.registration)) + .ok_or_else(|| STALE.into()) + } + pub fn retire(&mut self, host: &str, scope: &Scope) -> Result<(), String> { + let retired = ( + host.into(), + scope.workspace_id.clone(), + scope.incarnation.clone(), + ); + if !self.retired.contains(&retired) && self.retired.len() >= MAX_RETIRED { + return Err("requestLimitExceeded".into()); + } + self.retired.insert(retired); + let key = (host.into(), scope.workspace_id.clone()); + if self + .entries + .get(&key) + .is_some_and(|entry| entry.ticket.scope == *scope) + { + self.entries.remove(&key); + } + Ok(()) + } + pub fn invalidate(&mut self) { + // Retain incarnation tombstones for a later native window close, + // while invalidating every captured scan/ownership generation now. + for entry in self.entries.values_mut() { + entry.ticket.generation = 0; + entry.snapshot = None; + } + } + pub fn begin_write( + &mut self, + host: &str, + scope: &Scope, + generation: u64, + ) -> Result<(), String> { + self.capture(host, scope, generation)?; + self.entries + .get_mut(&(host.into(), scope.workspace_id.clone())) + .ok_or(STALE)? + .awaiting_write_refresh = true; + Ok(()) + } + pub fn invalidate_generation(&mut self, host: &str, scope: &Scope, generation: u64) -> bool { + let Some(entry) = self + .entries + .get_mut(&(host.into(), scope.workspace_id.clone())) + .filter(|entry| entry.ticket.scope == *scope && entry.ticket.generation == generation) + else { + return false; + }; + if entry.awaiting_write_refresh { + return false; + } + entry.ticket.generation = 0; + entry.snapshot = None; + true + } + pub fn annotate( + &mut self, + ticket: &Ticket, + mappings: super::mappings::MappingSnapshot, + watch_error: Option, + choices: Vec, + ) -> Result { + self.entry(ticket)?; + let entry = self + .entries + .get_mut(&(ticket.host.clone(), ticket.scope.workspace_id.clone())) + .ok_or(STALE)?; + let (snapshot, _) = entry.snapshot.as_mut().ok_or(STALE)?; + snapshot.mappings = Some(mappings); + snapshot.watch_error = watch_error; + snapshot.mapping_choices = choices; + Ok(snapshot.clone()) + } + pub fn close_window(&mut self, host: &str) { + let scopes: Vec<_> = self + .entries + .values() + .filter(|entry| entry.ticket.host == host) + .map(|entry| entry.ticket.scope.clone()) + .collect(); + for scope in scopes { + if self.retire(host, &scope).is_err() { + self.exhausted = true; + } + } + self.entries.retain(|(window, _), _| window != host); + } + pub fn discard(&mut self, ticket: &Ticket) { + if self.entry(ticket).is_ok() { + let entry = self + .entries + .get_mut(&(ticket.host.clone(), ticket.scope.workspace_id.clone())) + .expect("checked entry"); + entry.ticket.generation = 0; + entry.snapshot = None; + } + } +} + +fn ok(response: &Value) -> Result<(), String> { + if response["status"] == "ok" { + Ok(()) + } else { + Err(response + .get("error") + .cloned() + .unwrap_or(json!({"code":"invalidResponse"})) + .to_string()) + } +} +pub(super) fn same_roots(left: &Detection, right: &Detection) -> bool { + left.roots == right.roots + && left.ambiguous == right.ambiguous + && left.truncated == right.truncated +} +pub(super) fn detect( + core: &dyn VcsCore, + scope: &Scope, + packages: &[Package], +) -> Result { + detect_mapped(core, scope, packages, &[]) +} +pub(super) fn detect_mapped( + core: &dyn VcsCore, + scope: &Scope, + packages: &[Package], + mappings: &[super::mappings::Mapping], +) -> Result { + let response = core.execute("vcs.detectRoots", json!({"workspaceRoot":scope.workspace_root, + "plugins":packages.iter().map(|package| json!({"manifest":package.manifest})).collect::>(), "mappings":mappings}))?; + ok(&response)?; + serde_json::from_value(response["detection"].clone()).map_err(|_| "invalidResponse".into()) +} +pub(super) fn owners( + core: &dyn VcsCore, + snapshot: &Snapshot, + paths: &[String], +) -> Result { + let response = core.execute("vcs.ownerOf", json!({ + "roots":snapshot.detection.roots.iter().map(|root| &root.id).collect::>(), + "ambiguous":snapshot.detection.ambiguous.iter().map(|row| &row["id"]).collect::>(), "paths":paths}))?; + ok(&response)?; + let owners: Vec = + serde_json::from_value(response["owners"].clone()).map_err(|_| "invalidResponse")?; + if owners.len() != paths.len() + || owners + .iter() + .zip(paths) + .any(|(owner, path)| owner.path != *path) + { + return Err("invalidResponse".into()); + } + let common = owners + .first() + .and_then(|owner| owner.root.as_ref()) + .filter(|root| { + !snapshot.detection.truncated + && owners + .iter() + .all(|owner| owner.root.as_ref() == Some(*root)) + }); + let common_root = common + .and_then(|id| snapshot.detection.roots.iter().find(|root| root.id == *id)) + .cloned(); + Ok(Ownership { + generation: snapshot.generation, + owners, + common_root, + complete: !snapshot.detection.truncated, + }) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/vcs_session.rs b/windows/tauri/src-tauri/src/plugin_host/vcs_session.rs new file mode 100644 index 0000000000..6c63458da7 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/vcs_session.rs @@ -0,0 +1,607 @@ +//! One native owner per typed VCS request. Core owns framing, +//! negotiation, reply validation and the process tree; failed disposal retains +//! the same package lease and can be retried through the native request ID. +use super::registry::{Root, Scope}; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::Duration; + +pub(super) const CLEANUP_FAILED: &str = "vcs.provider.cleanupFailed"; +pub(super) const CANCELLED: &str = "requestCancelled"; +pub(super) type Reply = Result; + +pub(super) trait Runtime: Send + Sync { + fn execute(&self, command: &str, payload: Value) -> Reply; + fn start(&self, payload: Value, registered: &dyn Fn(&str)) -> Reply; +} +pub(super) struct CoreRuntime; +impl Runtime for CoreRuntime { + fn execute(&self, command: &str, payload: Value) -> Reply { + super::super::scan::core_execute(command, payload) + } + fn start(&self, payload: Value, registered: &dyn Fn(&str)) -> Reply { + lithe_core::start_lsp_owned(payload, registered).map_err(|_| "vcs.provider.failed".into()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct Context { + pub host: String, + pub scope: Scope, + pub generation: u64, + pub registration: u64, + pub request_id: String, + pub root: Root, + pub root_identity: String, + pub plugin_id: String, + pub package_identity: String, + pub settings_revision: u64, +} +impl Context { + pub fn settings_scope(&self, host: &str, workspace: &str, plugin: &str) -> bool { + self.host == host && self.scope.workspace_root == workspace && self.plugin_id == plugin + } +} +#[derive(Default)] +struct State { + session: Option, + launch_finished: bool, + disposal_running: bool, + disposed: bool, + tool: Option, + capabilities: Value, + mutation: bool, + write_attempted: bool, + write_answered: bool, +} +pub(super) struct Owner { + pub context: Context, + pub revoked: AtomicBool, + state: Mutex, + stopping: tokio::sync::Mutex<()>, + lease: Mutex>, + registry: Mutex>>, + runtime: Arc, +} +impl Owner { + pub fn new(context: Context, lease: super::super::lifecycle::NativeLease) -> Arc { + Arc::new(Self { + context, + revoked: AtomicBool::new(false), + state: Mutex::new(State { + launch_finished: true, + ..State::default() + }), + stopping: Default::default(), + lease: Mutex::new(Some(lease)), + registry: Mutex::new(Weak::new()), + runtime: Arc::new(CoreRuntime), + }) + } + pub fn revoke(&self) { + self.revoked.store(true, Ordering::Release); + } + pub fn current(&self) -> Result<(), String> { + if self.revoked.load(Ordering::Acquire) { + Err(CANCELLED.into()) + } else { + Ok(()) + } + } + pub fn session(&self) -> Option { + self.state.lock().expect("VCS session").session.clone() + } + fn cleanup_later(self: &Arc) { + if self.revoked.load(Ordering::Acquire) { + let registry = self.registry.lock().expect("VCS registry").upgrade(); + if let Some(registry) = registry { + super::status::cleanup_later(Arc::new(Sessions(registry)), self.clone()); + } + } + } + /// Register before Core's first process write. A cancelled/failed launch + /// remains reachable even if the blocking worker returns after its waiter. + pub async fn start( + self: &Arc, + resolve: impl FnOnce() -> Result<(Value, Option), String> + Send + 'static, + ) -> Result<(), String> { + self.state.lock().expect("VCS session").launch_finished = false; + let owner = self.clone(); + tokio::time::timeout( + Duration::from_secs(15), + tauri::async_runtime::spawn_blocking(move || { + struct Finished(Arc); + impl Drop for Finished { + fn drop(&mut self) { + self.0.state.lock().expect("VCS session").launch_finished = true; + // A window's last cleanup may have timed out while the + // native launcher was blocked. Its completion owns a + // fresh bounded cleanup even after that window is gone. + self.0.cleanup_later(); + } + } + let _finished = Finished(owner.clone()); + owner.current()?; + let (payload, tool) = resolve()?; + owner.current()?; + owner.state.lock().expect("VCS session").tool = tool; + let result = owner.runtime.start(payload, &|id| { + owner.state.lock().expect("VCS session").session = Some(id.into()); + })?; + if result["sessionId"].as_str() != owner.session().as_deref() { + return Err("invalidResponse".into()); + } + owner.current() + }), + ) + .await + .map_err(|_| "requestTimeout")? + .map_err(|_| "internalError")? + } + async fn call(self: &Arc, command: &'static str, payload: Value) -> Reply { + let owner = self.clone(); + tokio::time::timeout( + Duration::from_secs(5), + tauri::async_runtime::spawn_blocking(move || owner.runtime.execute(command, payload)), + ) + .await + .map_err(|_| "requestTimeout")? + .map_err(|_| "internalError")? + } + async fn destroy(self: &Arc, session: String) -> Reply { + { + let mut state = self.state.lock().expect("VCS session"); + if state.disposed { + return Ok(json!({})); + } + if state.disposal_running { + return Err(CLEANUP_FAILED.into()); + } + state.disposal_running = true; + } + let owner = self.clone(); + tokio::time::timeout( + Duration::from_secs(5), + tauri::async_runtime::spawn_blocking(move || { + struct Finished { + owner: Arc, + session: String, + succeeded: bool, + } + impl Drop for Finished { + fn drop(&mut self) { + let mut state = self.owner.state.lock().expect("VCS session"); + let same = state.session.as_deref() == Some(self.session.as_str()); + if same { + state.disposal_running = false; + // Core removes a successfully destroyed session. + // Preserve this proof in the original owner before + // its timed-out waiter can retry an unknown session. + state.disposed |= self.succeeded; + } + drop(state); + if same && self.succeeded { + self.owner.cleanup_later(); + } + } + } + let mut finished = Finished { + owner: owner.clone(), + session: session.clone(), + succeeded: false, + }; + let result = owner + .runtime + .execute("lsp.destroyServer", json!({"sessionId":session})); + finished.succeeded = result.is_ok(); + result + }), + ) + .await + .map_err(|_| "requestTimeout")? + .map_err(|_| "internalError")? + } + async fn events(self: &Arc) -> Result, String> { + self.current()?; + let session = self.session().ok_or("vcs.provider.failed")?; + let reply = self + .call( + "lsp.waitEvents", + json!({"sessionId":session,"timeoutMilliseconds":250}), + ) + .await?; + self.current()?; + reply["events"] + .as_array() + .cloned() + .ok_or_else(|| "invalidResponse".into()) + } + #[cfg(test)] + pub async fn status(self: &Arc, current: impl Fn() -> Result<(), String>) -> Reply { + self.read(&super::query::ReadOperation::Status, current) + .await + } + pub fn mark_mutation(&self) { + self.state.lock().expect("VCS session").mutation = true; + } + pub fn capabilities(&self) -> Value { + self.state.lock().expect("VCS session").capabilities.clone() + } + pub fn uncertain(&self) -> bool { + let state = self.state.lock().expect("VCS session"); + state.write_attempted && !state.write_answered + } + pub async fn read( + self: &Arc, + operation: &super::query::ReadOperation, + current: impl Fn() -> Result<(), String>, + ) -> Reply { + operation.validate()?; + tokio::time::timeout(Duration::from_secs(45), async { + let session = self.session().ok_or("vcs.provider.failed")?; + let mut negotiation = None; + let mut ready = false; + while !ready { + current()?; + for event in self.events().await? { + if event["sessionId"] != session { + continue; + } + match event["type"].as_str() { + Some("vcsProvider") => { + negotiation = Some(negotiated(&event, &self.context.root.vcs)?) + } + Some("stateChanged") if event["state"] == "ready" => ready = true, + Some("stateChanged") + if matches!( + event["state"].as_str(), + Some("failed" | "stopped" | "stopping") + ) => + { + return Err(failure(&event)) + } + _ => {} + } + } + } + self.state.lock().expect("VCS session").capabilities = + negotiation.ok_or("vcs.provider.unavailable")?; + self.request( + operation.name(), + operation.arguments(), + false, + || Ok(()), + current, + ) + .await + }) + .await + .map_err(|_| "requestTimeout")? + } + pub async fn write( + self: &Arc, + operation: &super::actions::WriteOperation, + preflight: impl FnOnce() -> Result<(), String> + Send + 'static, + current: impl Fn() -> Result<(), String>, + ) -> Reply { + tokio::time::timeout( + Duration::from_secs(615), + self.request( + operation.request_name(), + operation.arguments(), + true, + preflight, + current, + ), + ) + .await + .map_err(|_| "requestTimeout")? + } + async fn request( + self: &Arc, + name: &'static str, + arguments: Value, + write: bool, + preflight: impl FnOnce() -> Result<(), String> + Send + 'static, + current: impl Fn() -> Result<(), String>, + ) -> Reply { + let capabilities = self.capabilities(); + let capability = match name { + "resolve" => "conflicts", + "fileOperation" => "fileOperations", + _ => name, + }; + if capabilities[capability] != 1 { + return Err("vcs.capability.unsupported".into()); + } + current()?; + self.current()?; + let session = self.session().ok_or("vcs.provider.failed")?; + let tool = self.state.lock().expect("VCS session").tool.clone(); + // Status and mutation use distinct Core operation IDs in the same short + // session. A late status completion cannot acknowledge the write. + let id = if write { + format!("{}-write", self.context.request_id) + } else { + self.context.request_id.clone() + }; + let payload = json!({"sessionId":session,"operationId":id, + "vcs":self.context.root.vcs,"operation":name,"root":self.context.root.path, + "tool":tool,"arguments":arguments,"timeoutMilliseconds":if write {600000} else {30000}}); + let owner = self.clone(); + tokio::time::timeout( + Duration::from_secs(15), + tauri::async_runtime::spawn_blocking(move || { + preflight()?; + owner.current()?; + if write { + let mut state = owner.state.lock().expect("VCS session"); + // No retry path may ever dispatch this owner's write twice. + if state.write_attempted { + return Err("requestCancelled".into()); + } + state.write_attempted = true; + } + owner.runtime.execute("lsp.vcsRequest", payload) + }), + ) + .await + .map_err(|_| "requestTimeout")? + .map_err(|_| "internalError")??; + loop { + current()?; + for event in self.events().await? { + if event["sessionId"] != session { + continue; + } + if event["type"] == "stateChanged" + && matches!( + event["state"].as_str(), + Some("failed" | "stopped" | "stopping") + ) + { + return Err(failure(&event)); + } + if event["type"] != "requestCompleted" || event["operationId"] != id { + continue; + } + let value = match completed(&event) { + Ok(value) => value, + Err(error) => { + // A provider failure is a known failure. Transport loss, + // cancellation and invalid replies can hide side effects. + if write + && (event["error"]["code"] == "vcs.provider.failed" + || !matches!( + error.as_str(), + "requestTimeout" + | "requestCancelled" + | "plugin.vcs.invalidOutput" + | "vcs.provider.failed" + )) + { + self.state.lock().expect("VCS session").write_answered = true; + } + return Err(error); + } + }; + let decoded = self + .call( + "vcs.decodeReply", + json!({"operation":name,"capabilities":capabilities, + "request":arguments,"reply":value}), + ) + .await?; + if decoded["status"] != "ok" { + return Err("plugin.vcs.invalidOutput".into()); + } + let result = decoded.get("result").cloned().ok_or("invalidResponse")?; + current()?; + self.current()?; + if write { + self.state.lock().expect("VCS session").write_answered = true; + } + return Ok(result); + } + } + } + /// Success is proof that both the launch worker and Core's owned process + /// tree have ended. Neither a timeout nor a frontend cancellation is proof. + pub async fn stop(self: &Arc) -> Result<(), String> { + self.revoke(); + let _lock = tokio::time::timeout(Duration::from_secs(12), self.stopping.lock()) + .await + .map_err(|_| CLEANUP_FAILED)?; + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if self.dispose_once().await { + self.lease.lock().expect("VCS lease").take(); + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await + .map_err(|_| CLEANUP_FAILED)? + } + async fn dispose_once(self: &Arc) -> bool { + let (session, finished, disposed, running) = { + let state = self.state.lock().expect("VCS session"); + ( + state.session.clone(), + state.launch_finished, + state.disposed, + state.disposal_running, + ) + }; + if !disposed { + if let Some(session) = session { + if running { + return false; + } + let _ = self + .call("lsp.stopServer", json!({"sessionId":session})) + .await; + if self.destroy(session).await.is_err() { + return false; + } + } else if !finished { + return false; + } + } + finished + } +} + +pub(super) fn negotiated(event: &Value, vcs: &str) -> Reply { + if event["type"] != "vcsProvider" || event["result"]["vcs"] != vcs { + return Err("vcs.provider.unavailable".into()); + } + event["result"] + .get("capabilities") + .filter(|value| value.is_object()) + .cloned() + .ok_or_else(|| "invalidResponse".into()) +} +fn completed(event: &Value) -> Reply { + if !event["error"].is_null() { + return Err(failure(event)); + } + event["result"] + .get("value") + .cloned() + .ok_or_else(|| "plugin.vcs.invalidOutput".into()) +} +fn failure(event: &Value) -> String { + match event["error"]["code"].as_str().unwrap_or_default() { + "initializeTimeout" | "requestTimeout" => "requestTimeout", + "requestCancelled" => CANCELLED, + code @ ("vcs.authentication.failed" + | "vcs.conflict" + | "vcs.outOfDate" + | "vcs.tool.unavailable" + | "vcs.workingCopy.locked" + | "vcs.workingCopy.unsupported" + | "plugin.vcs.invalidOutput") => code, + _ => "vcs.provider.failed", + } + .into() +} + +#[derive(Default)] +struct Entries { + running: HashMap<(String, String), Arc>, + cancelled: HashSet<(String, String)>, +} +#[derive(Default)] +pub(super) struct Sessions(Arc>); +impl Sessions { + pub fn insert(&self, owner: Arc, active: impl FnOnce() -> bool) -> Result<(), String> { + let mut entries = self.0.lock().expect("VCS owners"); + if !active() + || entries + .cancelled + .contains(&(owner.context.host.clone(), owner.context.request_id.clone())) + { + return Err(CANCELLED.into()); + } + if entries.running.len() >= 64 { + return Err("requestLimitExceeded".into()); + } + let context = &owner.context; + if entries.running.values().any(|old| { + (old.context.host == context.host + && (old.context.request_id == context.request_id + || old.context.root.path == context.root.path)) + || old.context.root.path == context.root.path + && (old.state.lock().expect("VCS session").mutation + || owner.state.lock().expect("VCS session").mutation) + }) { + return Err(CLEANUP_FAILED.into()); + } + *owner.registry.lock().expect("VCS registry") = Arc::downgrade(&self.0); + entries + .running + .insert((context.host.clone(), context.request_id.clone()), owner); + Ok(()) + } + pub fn get(&self, host: &str, scope: &Scope, id: &str) -> Result>, String> { + let mut entries = self.0.lock().expect("VCS owners"); + let key = (host.into(), id.into()); + let owner = entries.running.get(&key).cloned(); + if owner + .as_ref() + .is_some_and(|owner| owner.context.scope != *scope) + { + return Err(CANCELLED.into()); + } + // Cancellation may beat a slow package/settings read. A later prepare + // cannot publish that request after Stop already acknowledged it. + if !entries.cancelled.contains(&key) && entries.cancelled.len() >= 4096 { + return Err("requestLimitExceeded".into()); + } + entries.cancelled.insert(key); + Ok(owner) + } + pub fn cancelled(&self, context: &Context) -> bool { + self.0 + .lock() + .expect("VCS owners") + .cancelled + .contains(&(context.host.clone(), context.request_id.clone())) + } + pub fn matching(&self, predicate: impl Fn(&Context) -> bool) -> Vec> { + self.0 + .lock() + .expect("VCS owners") + .running + .values() + .filter(|owner| predicate(&owner.context)) + .map(|owner| { + owner.revoke(); + owner.clone() + }) + .collect() + } + pub fn private_session(&self, command: &str, payload: &Value) -> bool { + command == "lsp.vcsRequest" + || command == "lsp.startServer" + && payload["providerId"] + .as_str() + .is_some_and(|id| id.starts_with("vcsProvider:")) + || command.starts_with("lsp.") + && self + .0 + .lock() + .expect("VCS owners") + .running + .values() + .any(|owner| { + owner.session().as_deref() == payload["sessionId"].as_str() + && owner.session().is_some() + }) + } + pub async fn cleanup(&self, owner: Arc) -> Result<(), String> { + owner.stop().await?; + let key = (owner.context.host.clone(), owner.context.request_id.clone()); + let mut entries = self.0.lock().expect("VCS owners"); + if entries + .running + .get(&key) + .is_some_and(|entry| Arc::ptr_eq(entry, &owner)) + { + entries.running.remove(&key); + } + Ok(()) + } +} + +#[cfg(test)] +#[path = "vcs_session_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "vcs_session_core_tests.rs"] +mod core_tests; diff --git a/windows/tauri/src-tauri/src/plugin_host/vcs_session_core_tests.rs b/windows/tauri/src-tauri/src/plugin_host/vcs_session_core_tests.rs new file mode 100644 index 0000000000..b5966ccd66 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/vcs_session_core_tests.rs @@ -0,0 +1,117 @@ +//! Opt-in native integration: real Core stdio transport and typed VCS requests. +//! The unified Windows lane supplies an absolute LITHE_VCS_TEST_NODE executable. +use super::*; +use crate::plugin_host::lifecycle::LifecycleState; +use std::path::PathBuf; +use std::sync::atomic::AtomicU64; + +static NEXT: AtomicU64 = AtomicU64::new(1); +struct Fixture(PathBuf); +impl Drop for Fixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).expect("remove native VCS fixture after Core disposal"); + } +} + +#[tokio::test] +#[ignore = "native integration; unified Windows lane supplies LITHE_VCS_TEST_NODE"] +async fn real_core_stdio_optional_writes_use_only_the_frozen_typed_arguments() { + let node = + PathBuf::from(std::env::var_os("LITHE_VCS_TEST_NODE").expect("explicit integration Node")); + assert!(node.is_absolute() && node.is_file()); + for operation in [ + super::super::actions::WriteOperation::Resolve { + paths: vec!["conflict.txt".into()], + }, + super::super::actions::WriteOperation::Cleanup {}, + super::super::actions::WriteOperation::Add { + paths: vec!["new.txt".into()], + }, + super::super::actions::WriteOperation::Delete { + paths: vec!["after.txt".into(), "before.txt".into()], + }, + ] { + let fixture = Fixture(std::env::temp_dir().join(format!( + "lithe-vcs-core-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + ))); + std::fs::create_dir_all(&fixture.0).unwrap(); + let script = fixture.0.join("provider.mjs"); + let receipt = fixture.0.join("writes.jsonl"); + std::fs::write(&script, include_str!("vcs_session_fixture.mjs")).unwrap(); + std::fs::write(fixture.0.join("conflict.txt"), "resolved contents").unwrap(); + let root = fixture + .0 + .canonicalize() + .unwrap() + .to_string_lossy() + .into_owned(); + let context = Context { + host: "native-vcs-integration".into(), + scope: Scope { + workspace_id: "fixture".into(), + workspace_root: root.clone(), + incarnation: "one".into(), + }, + generation: 1, + registration: 1, + request_id: format!("request-{}", operation.name()), + root: Root { + id: String::new(), + vcs: "fixture".into(), + path: root.clone(), + mapped: false, + }, + root_identity: "fixture-directory".into(), + plugin_id: "fixture.vcs".into(), + package_identity: "controlled-test-bytes".into(), + settings_revision: 0, + }; + let lifecycle = LifecycleState::default(); + let owner = Owner::new(context, lifecycle.acquire("fixture.vcs").unwrap()); + owner.mark_mutation(); + let sessions = Sessions::default(); + sessions.insert(owner.clone(), || true).unwrap(); + let payload = json!({"providerId":format!("vcsProvider:fixture:{}", operation.name()), + "pluginId":"fixture.vcs", "executablePath":node, + "arguments":[script,receipt,root], "environment":{}, + "rootUri":url::Url::from_directory_path(&fixture.0).unwrap().as_str(), + "workingDirectory":root, "initializeTimeoutMilliseconds":2000, "requestTimeoutMilliseconds":2000}); + // No assertion can bypass native cleanup. Deadline cancellation leaves + // the original owner registered, including a late-starting process. + let result = tokio::time::timeout(Duration::from_secs(5), async { + owner.start(move || Ok((payload, None))).await?; + let status = owner.status(|| Ok(())).await?; + if status["changes"][0]["status"] != "conflicted" + || owner.capabilities()[operation.capability()] != 1 + { + return Err("fixture negotiation/status mismatch".into()); + } + owner.write(&operation, || Ok(()), || Ok(())).await + }) + .await; + let cleaned = + tokio::time::timeout(Duration::from_secs(13), sessions.cleanup(owner.clone())).await; + assert_eq!(cleaned.expect("bounded native integration cleanup"), Ok(())); + assert!(owner.lease.lock().unwrap().is_none()); + assert_eq!( + result.expect("bounded real Core VCS request").unwrap(), + json!({}) + ); + let writes: Vec = std::fs::read_to_string(&receipt) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(writes.len(), 1); + assert_eq!( + writes[0]["method"], + format!("lithe/vcs/{}", operation.request_name()) + ); + let mut expected = operation.arguments(); + expected["root"] = json!({"path":root}); + expected["tool"] = Value::Null; + assert_eq!(writes[0]["params"], expected); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/vcs_session_fixture.mjs b/windows/tauri/src-tauri/src/plugin_host/vcs_session_fixture.mjs new file mode 100644 index 0000000000..337099ef86 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/vcs_session_fixture.mjs @@ -0,0 +1,81 @@ +// Native integration fixture: Core owns framing, negotiation and the child tree. +import { appendFileSync } from "node:fs"; + +const [receipt, expectedRoot] = process.argv.slice(2); +let input = Buffer.alloc(0); +function send(id, result, error) { + const body = Buffer.from( + JSON.stringify(error ? { jsonrpc: "2.0", id, error } : { jsonrpc: "2.0", id, result }), + ); + process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`); + process.stdout.write(body); +} +function handle({ id, method, params }) { + if (method === "exit") return process.exit(0); + if (id === undefined) return; + if (method === "initialize") + return send(id, { + capabilities: { + experimental: { + litheVcs: { + version: 1, + vcs: "fixture", + capabilities: { + status: 1, + baseContent: 1, + checkin: 1, + rollback: 1, + conflicts: 1, + cleanup: 1, + fileOperations: 1, + }, + }, + }, + }, + }); + if (method === "shutdown") return send(id, null); + if (method === "lithe/vcs/status") + return send(id, { + changes: [{ path: "conflict.txt", status: "conflicted" }], + }); + if (["lithe/vcs/resolve", "lithe/vcs/cleanup", "lithe/vcs/fileOperation"].includes(method)) { + appendFileSync(receipt, `${JSON.stringify({ method, params })}\n`); + const fileOperation = method.endsWith("/fileOperation"); + const expected = fileOperation + ? ["kind", "paths", "root", "tool"] + : method.endsWith("/resolve") + ? ["paths", "root", "tool"] + : ["root", "tool"]; + if ( + JSON.stringify(Object.keys(params).sort()) !== JSON.stringify(expected) || + params.root.path !== expectedRoot || + params.tool !== null || + (fileOperation && + (!["add", "delete"].includes(params.kind) || + JSON.stringify(params.paths) !== + (params.kind === "add" ? '["new.txt"]' : '["after.txt","before.txt"]'))) || + (method.endsWith("/resolve") && JSON.stringify(params.paths) !== '["conflict.txt"]') + ) + return send(id, null, { + code: -32602, + message: "Unexpected typed VCS parameters", + }); + return send(id, {}); + } + send(id, null, { code: -32601, message: "Unsupported fixture request" }); +} +process.stdin.on("data", (chunk) => { + input = Buffer.concat([input, chunk]); + if (input.length > 65536) return process.exit(2); + while (true) { + const end = input.indexOf("\r\n\r\n"); + if (end < 0) return; + const match = /^Content-Length: (\d+)$/im.exec(input.subarray(0, end).toString()); + if (!match) return process.exit(2); + const length = Number(match[1]); + if (input.length < end + 4 + length) return; + const body = input.subarray(end + 4, end + 4 + length); + input = input.subarray(end + 4 + length); + handle(JSON.parse(body)); + } +}); diff --git a/windows/tauri/src-tauri/src/plugin_host/vcs_session_tests.rs b/windows/tauri/src-tauri/src/plugin_host/vcs_session_tests.rs new file mode 100644 index 0000000000..b9f889c4db --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/vcs_session_tests.rs @@ -0,0 +1,1004 @@ +//! Deterministic transport boundaries; Core decodes the real status contract. +use super::*; +use crate::plugin_host::lifecycle::LifecycleState; +use std::collections::VecDeque; + +const DEADLINE: Duration = Duration::from_secs(2); +#[derive(Default)] +struct ScriptedRuntime { + calls: Mutex>, + events: Mutex>>, + reject_disposal: AtomicBool, + reject_start: AtomicBool, +} +impl Runtime for ScriptedRuntime { + fn execute(&self, command: &str, payload: Value) -> Reply { + self.calls + .lock() + .unwrap() + .push((command.into(), payload.clone())); + match command { + "lsp.waitEvents" => self + .events + .lock() + .unwrap() + .pop_front() + .map(|events| json!({"events":events})) + .ok_or_else(|| "fixture event script exhausted".into()), + "vcs.decodeReply" => crate::plugin_host::scan::core_execute(command, payload), + "lsp.destroyServer" if self.reject_disposal.load(Ordering::Acquire) => { + Err(CLEANUP_FAILED.into()) + } + _ => Ok(json!({})), + } + } + fn start(&self, payload: Value, registered: &dyn Fn(&str)) -> Reply { + self.calls + .lock() + .unwrap() + .push(("lsp.startServer".into(), payload)); + registered("fixture-session"); + if self.reject_start.load(Ordering::Acquire) { + Err("fixture first write failed".into()) + } else { + Ok(json!({"sessionId":"fixture-session"})) + } + } +} +fn context() -> Context { + Context { + host: "workspace-fixture".into(), + scope: Scope { + workspace_id: "fixture".into(), + workspace_root: std::env::temp_dir().to_string_lossy().into_owned(), + incarnation: "one".into(), + }, + generation: 7, + registration: 3, + request_id: "fixture-request".into(), + root: Root { + id: "legacy".into(), + vcs: "svn".into(), + path: std::env::temp_dir() + .join("legacy") + .to_string_lossy() + .into_owned(), + mapped: false, + }, + root_identity: "fixture-directory-identity".into(), + plugin_id: "fixture.plugin".into(), + package_identity: "whole-package-digest".into(), + settings_revision: 0, + } +} + +#[test] +fn scoped_settings_reload_revokes_only_its_plugin_and_original_workspace_window() { + let sessions = Sessions::default(); + let original = context(); + let mut contexts = vec![original.clone(); 4]; + contexts[1].host = "another-window".into(); + contexts[2].plugin_id = "another.plugin".into(); + contexts[2].root.path.push_str("-another-plugin"); + contexts[3] + .scope + .workspace_root + .push_str("-another-workspace"); + contexts[3].root.path.push_str("-another-workspace"); + let mut owners = Vec::new(); + for (index, mut context) in contexts.into_iter().enumerate() { + context.request_id = format!("scope-request-{index}"); + let owner = owner(context, Arc::new(ScriptedRuntime::default())); + sessions.insert(owner.clone(), || true).unwrap(); + owners.push(owner); + } + let revoked = sessions.matching(|context| { + context.settings_scope( + &original.host, + &original.scope.workspace_root, + &original.plugin_id, + ) + }); + assert_eq!(revoked.len(), 1); + assert!(Arc::ptr_eq(&revoked[0], &owners[0])); + assert_eq!(owners[0].current().unwrap_err(), CANCELLED); + assert!(owners[1..].iter().all(|owner| owner.current().is_ok())); +} +fn owner(context: Context, runtime: Arc) -> Arc { + let lifecycle = LifecycleState::default(); + let lease = lifecycle.acquire(&context.plugin_id).unwrap(); + let mut owner = Owner::new(context, lease); + Arc::get_mut(&mut owner).unwrap().runtime = runtime; + owner +} + +struct StartGate(Option>); +impl StartGate { + fn release(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(()); + } + } +} +impl Drop for StartGate { + fn drop(&mut self) { + self.release(); + } +} +struct DelayedStartRuntime { + script: Arc, + entered: Mutex>>, + release: Mutex>, + destroyed: Mutex>>, +} +impl Runtime for DelayedStartRuntime { + fn execute(&self, command: &str, payload: Value) -> Reply { + let result = self.script.execute(command, payload); + if command == "lsp.destroyServer" { + if let Some(sender) = self.destroyed.lock().unwrap().take() { + let _ = sender.send(()); + } + } + result + } + fn start(&self, payload: Value, registered: &dyn Fn(&str)) -> Reply { + if let Some(sender) = self.entered.lock().unwrap().take() { + let _ = sender.send(()); + } + // The native boundary is blocking; its gate is released on failure + // as well as success, and its own deadline bounds a failed test. + self.release + .lock() + .unwrap() + .recv_timeout(DEADLINE) + .map_err(|_| "fixture launch gate timed out".to_string())?; + self.script.start(payload, registered) + } +} +struct DelayedDestroyRuntime { + script: Arc, + entered: Mutex>>, + release: Mutex>, + first: AtomicBool, + destroyed: AtomicBool, + reject_first: bool, +} +impl Runtime for DelayedDestroyRuntime { + fn execute(&self, command: &str, payload: Value) -> Reply { + let result = self.script.execute(command, payload); + if command != "lsp.destroyServer" { + return result; + } + if self.first.swap(false, Ordering::AcqRel) { + if let Some(sender) = self.entered.lock().unwrap().take() { + let _ = sender.send(()); + } + // Only the native blocking worker waits here. Drop releases the + // gate after assertions fail, and this local deadline also bounds it. + self.release + .lock() + .unwrap() + .recv_timeout(DEADLINE) + .map_err(|_| "fixture disposal gate timed out".to_string())?; + if self.reject_first { + return Err(CLEANUP_FAILED.into()); + } + } + if self.destroyed.swap(true, Ordering::AcqRel) { + return Err("unknown_session".into()); + } + Ok(json!({})) + } + fn start(&self, payload: Value, registered: &dyn Fn(&str)) -> Reply { + self.script.start(payload, registered) + } +} +fn capabilities() -> Value { + json!({"status":1,"baseContent":1,"checkin":1,"rollback":1}) +} +fn ready(vcs: &str) -> Vec { + vec![ + json!({"type":"vcsProvider","sessionId":"fixture-session","result":{"vcs":vcs,"capabilities":capabilities()}}), + json!({"type":"stateChanged","sessionId":"fixture-session","state":"ready"}), + ] +} + +#[tokio::test] +async fn status_uses_core_negotiation_shape_request_and_real_reply_decoder() { + tokio::time::timeout(DEADLINE,async { + let runtime=Arc::new(ScriptedRuntime::default()); + runtime.events.lock().unwrap().extend([ready("svn"),vec![json!({"type":"requestCompleted","sessionId":"fixture-session","operationId":"foreign","result":{"value":{}}}), + json!({"type":"requestCompleted","sessionId":"fixture-session","operationId":"fixture-request","result":{"value":{ + "head":{"location":"https://user:secret@example.invalid/repo?token=hidden#fragment"}, + "changes":[{"path":"z.txt","status":"modified"},{"path":"a.txt","status":"added"}]}}})]]); + let owner=owner(context(),runtime.clone()); + owner.start(|| Ok((json!({"pluginId":"fixture.plugin"}),Some("fixture-vcs-tool".into())))).await.unwrap(); + let result=owner.status(|| Ok(())).await.unwrap(); + assert_eq!(result["changes"][0]["path"],"a.txt"); + assert_eq!(result["head"]["location"],"https://example.invalid/repo"); + let calls=runtime.calls.lock().unwrap().clone(); + let payload=&calls.iter().find(|(command,_)| command=="lsp.vcsRequest").unwrap().1; + assert_eq!(payload["operation"],"status"); + assert_eq!(payload["root"],owner.context.root.path); + assert_eq!(payload["tool"],"fixture-vcs-tool"); + assert_eq!(payload["arguments"],json!({"includeIgnored":false})); + assert!(calls.iter().any(|(command,_)| command=="vcs.decodeReply")); + owner.stop().await.unwrap(); + assert!(owner.lease.lock().unwrap().is_none()); + }).await.expect("bounded native status"); +} + +#[tokio::test] +async fn a_different_negotiated_vcs_never_receives_a_status_request() { + tokio::time::timeout(DEADLINE, async { + let runtime = Arc::new(ScriptedRuntime::default()); + runtime.events.lock().unwrap().push_back(ready("foreign")); + let owner = owner(context(), runtime.clone()); + owner.start(|| Ok((json!({}), None))).await.unwrap(); + assert_eq!( + owner.status(|| Ok(())).await.unwrap_err(), + "vcs.provider.unavailable" + ); + assert!(!runtime + .calls + .lock() + .unwrap() + .iter() + .any(|(command, _)| command == "lsp.vcsRequest")); + owner.stop().await.unwrap(); + }) + .await + .expect("bounded rejected negotiation"); +} + +#[tokio::test] +async fn typed_history_file_history_and_baseline_reads_share_the_core_decoder_and_owner() { + use super::super::query::ReadOperation; + tokio::time::timeout(DEADLINE, async { + for (operation, reply) in [ + (ReadOperation::History { cursor: Some("opaque-before".into()), limit: 2 }, + json!({"entries":[{"revision":{"id":"9","kind":"number"},"changes":[]}],"nextCursor":"opaque-after"})), + (ReadOperation::FileHistory { path: "deleted.txt".into(), cursor: None, limit: 2 }, + json!({"entries":[],"nextCursor":null})), + (ReadOperation::BaseContent { path: "old.txt".into(), current_path: "new.txt".into() }, + json!({"content":null,"binary":false})), + ] { + let runtime = Arc::new(ScriptedRuntime::default()); + let mut negotiated = ready("svn"); + negotiated[0]["result"]["capabilities"][operation.name()] = json!(1); + runtime.events.lock().unwrap().extend([ + negotiated, + vec![json!({"type":"requestCompleted","sessionId":"fixture-session", + "operationId":"fixture-request","result":{"value":reply}})], + ]); + let owner = owner(context(), runtime.clone()); + owner.start(|| Ok((json!({}), None))).await.unwrap(); + let result = owner.read(&operation, || Ok(())).await.unwrap(); + owner.stop().await.unwrap(); + let calls = runtime.calls.lock().unwrap(); + let request = &calls.iter().find(|(name, _)| name == "lsp.vcsRequest").unwrap().1; + assert_eq!(request["operation"], operation.name()); + assert_eq!(request["arguments"], operation.arguments()); + assert!(calls.iter().any(|(name, payload)| name == "vcs.decodeReply" && payload["operation"] == operation.name())); + assert!(calls.iter().any(|(name, _)| name == "lsp.destroyServer")); + if operation.name() == "history" { + assert_eq!(result["entries"][0]["revision"]["id"], "9"); + assert_eq!(result["nextCursor"], "opaque-after"); + } + } + }).await.expect("bounded typed VCS reads"); +} + +#[tokio::test] +async fn malformed_status_is_rejected_by_core_and_never_becomes_a_snapshot() { + tokio::time::timeout(DEADLINE,async { + let runtime=Arc::new(ScriptedRuntime::default()); + runtime.events.lock().unwrap().extend([ready("svn"),vec![json!({"type":"requestCompleted","sessionId":"fixture-session","operationId":"fixture-request", "result":{"value":{"changes":[{"path":"../escape","status":"modified"}]}}})]]); + let owner=owner(context(),runtime); + owner.start(|| Ok((json!({}),None))).await.unwrap(); + assert_eq!(owner.status(|| Ok(())).await.unwrap_err(),"plugin.vcs.invalidOutput"); + owner.stop().await.unwrap(); + }).await.expect("bounded rejected reply"); +} + +#[tokio::test] +async fn core_initialize_and_request_timeouts_keep_their_category_and_cleanup_owner() { + tokio::time::timeout(DEADLINE, async { + for initialize in [true, false] { + let runtime = Arc::new(ScriptedRuntime::default()); + if initialize { + runtime.events.lock().unwrap().push_back(vec![json!({"type":"stateChanged", + "sessionId":"fixture-session","state":"failed","error":{"code":"initializeTimeout"}})]); + } else { + runtime.events.lock().unwrap().extend([ready("svn"),vec![json!({"type":"requestCompleted", + "sessionId":"fixture-session","operationId":"fixture-request","error":{"code":"requestTimeout"}})]]); + } + let owner = owner(context(), runtime.clone()); + owner.start(|| Ok((json!({}), None))).await.unwrap(); + assert_eq!(owner.status(|| Ok(())).await.unwrap_err(), "requestTimeout"); + assert!(owner.lease.lock().unwrap().is_some()); + owner.stop().await.unwrap(); + assert!(owner.lease.lock().unwrap().is_none()); + let calls = runtime.calls.lock().unwrap(); + assert_eq!(calls.iter().any(|(command,_)| command == "lsp.vcsRequest"), !initialize); + assert!(calls.iter().any(|(command,_)| command == "lsp.destroyServer")); + } + }).await.expect("bounded timeout events"); +} + +#[test] +fn completed_requests_require_the_core_value_envelope_and_keep_only_known_failure_categories() { + assert_eq!( + completed(&json!({"result":{"changes":[]}})).unwrap_err(), + "plugin.vcs.invalidOutput" + ); + assert_eq!( + completed(&json!({"result":{"value":{"changes":[]}}})).unwrap(), + json!({"changes":[]}) + ); + assert_eq!( + completed(&json!({"error":{"code":"vcs.authentication.failed"}})).unwrap_err(), + "vcs.authentication.failed" + ); + assert_eq!( + completed(&json!({"error":{"code":"untrusted.plugin.failure"}})).unwrap_err(), + "vcs.provider.failed" + ); +} + +#[tokio::test] +async fn failed_destroy_keeps_the_native_owner_and_lease_until_retry_confirms_cleanup() { + tokio::time::timeout(DEADLINE, async { + let runtime = Arc::new(ScriptedRuntime::default()); + runtime.reject_disposal.store(true, Ordering::Release); + let owner = owner(context(), runtime.clone()); + owner.state.lock().unwrap().session = Some("fixture-session".into()); + let sessions = Sessions::default(); + sessions.insert(owner.clone(), || true).unwrap(); + assert!(!owner.dispose_once().await); + assert!(owner.lease.lock().unwrap().is_some()); + assert_eq!(sessions.matching(|_| true).len(), 1); + runtime.reject_disposal.store(false, Ordering::Release); + sessions.cleanup(owner.clone()).await.unwrap(); + assert!(owner.lease.lock().unwrap().is_none()); + assert!(sessions.matching(|_| true).is_empty()); + }) + .await + .expect("bounded cleanup retry"); +} + +#[tokio::test] +async fn a_late_launch_worker_retains_its_lease_after_the_process_tree_is_destroyed() { + tokio::time::timeout(DEADLINE, async { + let owner = owner(context(), Arc::new(ScriptedRuntime::default())); + { + let mut state = owner.state.lock().unwrap(); + state.session = Some("fixture-session".into()); + state.launch_finished = false; + } + assert!(!owner.dispose_once().await); + assert!(owner.lease.lock().unwrap().is_some()); + owner.state.lock().unwrap().launch_finished = true; + owner.stop().await.unwrap(); + assert!(owner.lease.lock().unwrap().is_none()); + }) + .await + .expect("bounded late worker cleanup"); +} + +#[tokio::test] +async fn a_late_start_after_window_cleanup_deadline_is_automatically_destroyed() { + tokio::time::timeout(DEADLINE, async { + let script = Arc::new(ScriptedRuntime::default()); + let (entered, entered_rx) = tokio::sync::oneshot::channel(); + let (release, release_rx) = std::sync::mpsc::channel(); + let (destroyed, destroyed_rx) = tokio::sync::oneshot::channel(); + let mut gate = StartGate(Some(release)); + let runtime = Arc::new(DelayedStartRuntime { + script: script.clone(), + entered: Mutex::new(Some(entered)), + release: Mutex::new(release_rx), + destroyed: Mutex::new(Some(destroyed)), + }); + let owner = owner(context(), runtime); + let sessions = Arc::new(Sessions::default()); + sessions.insert(owner.clone(), || true).unwrap(); + let starting = owner.start(|| Ok((json!({}), None))); + tokio::pin!(starting); + tokio::select! { + result = &mut starting => panic!("start must wait for its controlled gate: {result:?}"), + result = entered_rx => result.expect("native launcher reached its gate"), + } + // Window destruction revokes this exact owner. Expire its caller's + // cleanup budget before allowing Core to register the late process. + let closing = sessions.matching(|context| context.host == "workspace-fixture"); + assert_eq!(closing.len(), 1); + assert!( + tokio::time::timeout(Duration::ZERO, sessions.cleanup(closing[0].clone())) + .await + .is_err() + ); + assert!(owner.session().is_none()); + assert!(owner.lease.lock().unwrap().is_some()); + gate.release(); + assert_eq!(starting.await.unwrap_err(), CANCELLED); + // Only the completion reaper can produce this signal: the original + // cleanup ended and the test has not requested another cleanup yet. + destroyed_rx + .await + .expect("late process was automatically destroyed"); + // Drain the serialized owner before assertions/teardown; a concurrent + // completion cleanup must not issue duplicate stop/destroy commands. + sessions.cleanup(owner.clone()).await.unwrap(); + assert!(owner.lease.lock().unwrap().is_none()); + assert!(sessions.matching(|_| true).is_empty()); + let calls = script.calls.lock().unwrap(); + for expected in ["lsp.stopServer", "lsp.destroyServer"] { + assert_eq!( + calls + .iter() + .filter(|(command, _)| command == expected) + .count(), + 1 + ); + } + }) + .await + .expect("bounded late launch completion cleanup"); +} + +#[tokio::test] +async fn a_late_cleanup_cannot_remove_or_stop_a_replacement_owner() { + tokio::time::timeout(DEADLINE, async { + let sessions = Sessions::default(); + let previous = owner(context(), Arc::new(ScriptedRuntime::default())); + previous.state.lock().unwrap().session = Some("previous-session".into()); + sessions.insert(previous.clone(), || true).unwrap(); + sessions.cleanup(previous.clone()).await.unwrap(); + + // Reuse the request key after confirmed disposal. A completion cleanup + // still carrying the old Arc must leave the replacement's lease intact. + let runtime = Arc::new(ScriptedRuntime::default()); + let replacement = owner(context(), runtime.clone()); + replacement.state.lock().unwrap().session = Some("replacement-session".into()); + sessions.insert(replacement.clone(), || true).unwrap(); + sessions.cleanup(previous).await.unwrap(); + { + let entries = sessions.0.lock().unwrap(); + let key = (context().host, context().request_id); + assert!(Arc::ptr_eq( + entries.running.get(&key).unwrap(), + &replacement + )); + } + assert!(replacement.lease.lock().unwrap().is_some()); + assert!(runtime.calls.lock().unwrap().is_empty()); + sessions.cleanup(replacement.clone()).await.unwrap(); + assert!(replacement.lease.lock().unwrap().is_none()); + }) + .await + .expect("bounded cleanup owner identity"); +} + +#[tokio::test] +async fn a_destroy_worker_keeps_its_completion_proof_after_its_waiter_times_out() { + tokio::time::timeout(DEADLINE, async { + for reject_first in [false, true] { + let script = Arc::new(ScriptedRuntime::default()); + let (entered, entered_rx) = tokio::sync::oneshot::channel(); + let (release, release_rx) = std::sync::mpsc::channel(); + let mut gate = StartGate(Some(release)); + let runtime = Arc::new(DelayedDestroyRuntime { + script: script.clone(), + entered: Mutex::new(Some(entered)), + release: Mutex::new(release_rx), + first: AtomicBool::new(true), + destroyed: AtomicBool::new(false), + reject_first, + }); + let owner = owner(context(), runtime); + owner.state.lock().unwrap().session = Some("fixture-session".into()); + let sessions = Sessions::default(); + sessions.insert(owner.clone(), || true).unwrap(); + owner.revoke(); + { + let disposing = owner.dispose_once(); + tokio::pin!(disposing); + tokio::select! { + result = &mut disposing => panic!("destroy must wait for its gate: {result:?}"), + result = entered_rx => result.expect("native destroy reached its gate"), + } + // Expire a caller's budget while its native destroy is still + // running. A retry must not issue this non-idempotent RPC twice. + assert!(tokio::time::timeout(Duration::ZERO, &mut disposing) + .await + .is_err()); + assert!(!owner.dispose_once().await); + assert!(owner.lease.lock().unwrap().is_some()); + if reject_first { + gate.release(); + assert!(!disposing.await); + let state = owner.state.lock().unwrap(); + assert!(!state.disposal_running); + assert!(!state.disposed); + assert!(owner.lease.lock().unwrap().is_some()); + } + } + // The successful branch drops its waiter before native completion. + // Drain the same owner to synchronize any completion cleanup before + // teardown; the original successful RPC remains the only destroy. + gate.release(); + sessions.cleanup(owner.clone()).await.unwrap(); + assert!(owner.lease.lock().unwrap().is_none()); + assert!(sessions.0.lock().unwrap().running.is_empty()); + let calls = script.calls.lock().unwrap(); + assert_eq!( + calls + .iter() + .filter(|(command, _)| command == "lsp.destroyServer") + .count(), + if reject_first { 2 } else { 1 } + ); + } + }) + .await + .expect("bounded native disposal completion proof"); +} + +#[tokio::test] +async fn a_failed_first_write_still_has_a_registered_process_owner_to_destroy() { + tokio::time::timeout(DEADLINE, async { + let runtime = Arc::new(ScriptedRuntime::default()); + runtime.reject_start.store(true, Ordering::Release); + let owner = owner(context(), runtime.clone()); + assert!(owner.start(|| Ok((json!({}), None))).await.is_err()); + assert_eq!(owner.session().as_deref(), Some("fixture-session")); + assert!(owner.lease.lock().unwrap().is_some()); + owner.stop().await.unwrap(); + assert!(runtime + .calls + .lock() + .unwrap() + .iter() + .any(|(command, _)| command == "lsp.destroyServer")); + }) + .await + .expect("bounded first-write cleanup"); +} + +#[test] +fn exact_roots_and_windows_are_independent_but_a_new_generation_cannot_overlap_an_old_owner() { + let sessions = Sessions::default(); + sessions + .insert( + owner(context(), Arc::new(ScriptedRuntime::default())), + || true, + ) + .unwrap(); + let mut newer = context(); + newer.generation += 1; + newer.scope.incarnation = "reopened".into(); + newer.scope.workspace_id = "reopened-workspace".into(); + newer.request_id = "next".into(); + assert_eq!( + sessions + .insert(owner(newer, Arc::new(ScriptedRuntime::default())), || true) + .unwrap_err(), + CLEANUP_FAILED + ); + let mut other = context(); + other.root.id = "other".into(); + other.root.path.push_str("-other"); + other.request_id = "other".into(); + sessions + .insert(owner(other, Arc::new(ScriptedRuntime::default())), || true) + .unwrap(); + let mut window = context(); + window.host = "workspace-other-window".into(); + sessions + .insert(owner(window, Arc::new(ScriptedRuntime::default())), || true) + .unwrap(); + assert_eq!( + sessions + .matching(|context| context.host == "workspace-fixture") + .len(), + 2 + ); + assert_eq!( + sessions + .matching(|context| context.host == "workspace-other-window") + .len(), + 1 + ); +} + +#[test] +fn cancellation_before_preparation_finishes_prevents_late_publication() { + let sessions = Sessions::default(); + let context = context(); + assert!(sessions + .get(&context.host, &context.scope, &context.request_id) + .unwrap() + .is_none()); + assert_eq!( + sessions + .insert(owner(context, Arc::new(ScriptedRuntime::default())), || { + true + }) + .unwrap_err(), + CANCELLED + ); +} + +#[tokio::test] +async fn cancellation_after_tree_cleanup_still_rejects_a_late_projection() { + tokio::time::timeout(DEADLINE, async { + let context = context(); + let sessions = Sessions::default(); + let owner = owner(context.clone(), Arc::new(ScriptedRuntime::default())); + sessions.insert(owner.clone(), || true).unwrap(); + sessions.cleanup(owner).await.unwrap(); + assert!(!sessions.cancelled(&context)); + assert!(sessions + .get(&context.host, &context.scope, &context.request_id) + .unwrap() + .is_none()); + assert!(sessions.cancelled(&context)); + }) + .await + .expect("bounded projection cancellation"); +} + +#[test] +fn product_pages_cannot_drain_write_or_destroy_a_private_native_vcs_session() { + let sessions = Sessions::default(); + let owner = owner(context(), Arc::new(ScriptedRuntime::default())); + owner.state.lock().unwrap().session = Some("fixture-session".into()); + sessions.insert(owner, || true).unwrap(); + for command in [ + "lsp.waitEvents", + "lsp.pollEvents", + "lsp.request", + "lsp.stopServer", + "lsp.destroyServer", + ] { + assert!( + sessions.private_session(command, &json!({"sessionId":"fixture-session"})), + "{command}" + ); + assert!( + !sessions.private_session(command, &json!({"sessionId":"editor-session"})), + "{command}" + ); + } + assert!(sessions.private_session("lsp.vcsRequest", &json!({"sessionId":"unknown"}))); + assert!(sessions.private_session( + "lsp.startServer", + &json!({"providerId":"vcsProvider:fixture:svn:request"}) + )); +} + +#[tokio::test] +async fn writes_use_typed_core_requests_real_decoder_and_never_redispatch_on_cleanup_retry() { + tokio::time::timeout(DEADLINE, async { + for (operation, reply) in [ + (super::super::actions::WriteOperation::Checkin { paths: vec!["after.txt".into(), "before.txt".into()], message: "Fix selected".into() }, json!({"revision":{"id":"r2"}})), + (super::super::actions::WriteOperation::Rollback { paths: vec!["gone.txt".into()] }, json!({})), + (super::super::actions::WriteOperation::Update, json!({"revision":null,"conflicts":["a.txt"]})), + (super::super::actions::WriteOperation::Resolve { paths: vec!["conflict.txt".into()] }, json!({})), + (super::super::actions::WriteOperation::Cleanup {}, json!({})), + (super::super::actions::WriteOperation::Add { paths: vec!["new.txt".into()] }, json!({})), + (super::super::actions::WriteOperation::Delete { paths: vec!["after.txt".into(), "before.txt".into()] }, json!({})), + ] { + let runtime = Arc::new(ScriptedRuntime::default()); + let mut negotiation = ready("svn"); + negotiation[0]["result"]["capabilities"]["update"] = json!(1); + negotiation[0]["result"]["capabilities"]["conflicts"] = json!(1); + negotiation[0]["result"]["capabilities"]["cleanup"] = json!(1); + negotiation[0]["result"]["capabilities"]["fileOperations"] = json!(1); + runtime.events.lock().unwrap().extend([negotiation, vec![json!({"type":"requestCompleted","sessionId":"fixture-session","operationId":"fixture-request","result":{"value":{"changes":[]}}})], + vec![json!({"type":"requestCompleted","sessionId":"fixture-session","operationId":"fixture-request-write","result":{"value":reply}})]]); + let owner = owner(context(), runtime.clone()); + owner.mark_mutation(); + owner.start(|| Ok((json!({}), None))).await.unwrap(); + owner.status(|| Ok(())).await.unwrap(); + let result = owner.write(&operation, || Ok(()), || Ok(())).await.unwrap(); + assert!(!owner.uncertain()); + if operation.name() == "update" { assert_eq!(result["conflicts"], json!(["a.txt"])); } + // Exercise the public Core request boundary too; the controlled + // transport has no real session, so Core must reject that identity. + let request = runtime.calls.lock().unwrap().iter().rev().find(|(command, _)| command == "lsp.vcsRequest").unwrap().1.clone(); + assert_eq!(request["operation"], operation.request_name()); + assert_eq!(request["arguments"], operation.arguments()); + assert_eq!(request["operationId"], "fixture-request-write"); + let rejected = crate::plugin_host::scan::core_execute("lsp.vcsRequest", request); + assert!(rejected.is_err()); + runtime.reject_disposal.store(true, Ordering::Release); + assert!(!owner.dispose_once().await); + assert!(owner.lease.lock().unwrap().is_some()); + runtime.reject_disposal.store(false, Ordering::Release); + owner.stop().await.unwrap(); + assert!(owner.write(&operation, || Ok(()), || Ok(())).await.is_err()); + assert_eq!(runtime.calls.lock().unwrap().iter().filter(|(command, payload)| command == "lsp.vcsRequest" && payload["operation"] == operation.request_name()).count(), 1); + } + }).await.expect("bounded writes and cleanup"); +} + +#[tokio::test] +async fn optional_writes_never_dispatch_without_their_actual_negotiated_capability() { + tokio::time::timeout(DEADLINE, async { + for operation in [ + super::super::actions::WriteOperation::Resolve { paths: vec!["a.txt".into()] }, + super::super::actions::WriteOperation::Cleanup {}, + super::super::actions::WriteOperation::Add { paths: vec!["a.txt".into()] }, + super::super::actions::WriteOperation::Delete { paths: vec!["a.txt".into()] }, + ] { + let runtime = Arc::new(ScriptedRuntime::default()); + let mut negotiation = ready("svn"); + // A capability named after the operation is not a conflicts negotiation. + negotiation[0]["result"]["capabilities"]["resolve"] = json!(1); + negotiation[0]["result"]["capabilities"]["fileOperation"] = json!(1); + runtime.events.lock().unwrap().extend([negotiation, + vec![json!({"type":"requestCompleted","sessionId":"fixture-session","operationId":"fixture-request","result":{"value":{"changes":[]}}})]]); + let owner = owner(context(), runtime.clone()); + owner.start(|| Ok((json!({}), None))).await.unwrap(); + owner.status(|| Ok(())).await.unwrap(); + let result = owner.write(&operation, || Ok(()), || Ok(())).await; + assert_eq!(result.unwrap_err(), "vcs.capability.unsupported"); + assert!(!owner.uncertain()); + assert!(!runtime.calls.lock().unwrap().iter().any(|(name, payload)| name == "lsp.vcsRequest" && payload["operation"] == operation.request_name())); + owner.stop().await.unwrap(); + } + }).await.expect("bounded negotiated resolve/cleanup"); +} + +#[tokio::test] +async fn optional_write_outcomes_and_failed_disposal_keep_the_original_owner_without_replay() { + tokio::time::timeout(DEADLINE, async { + for operation in [ + super::super::actions::WriteOperation::Resolve { paths: vec!["a.txt".into()] }, + super::super::actions::WriteOperation::Cleanup {}, + super::super::actions::WriteOperation::Add { paths: vec!["a.txt".into()] }, + super::super::actions::WriteOperation::Delete { paths: vec!["a.txt".into()] }, + ] { + for outcome in ["preflight", "provider", "providerGeneric", "success", "successNull", "lost", "cancelled", "timeout", "invalidReply"] { + let runtime = Arc::new(ScriptedRuntime::default()); + let mut negotiation = ready("svn"); + negotiation[0]["result"]["capabilities"][operation.capability()] = json!(1); + runtime.events.lock().unwrap().extend([negotiation, + vec![json!({"type":"requestCompleted","sessionId":"fixture-session","operationId":"fixture-request","result":{"value":{"changes":[]}}})]]); + let completion = match outcome { + "provider" => Some(json!({"error":{"code":"vcs.workingCopy.locked"}})), + "providerGeneric" => Some(json!({"error":{"code":"vcs.provider.failed"}})), + "success" => Some(json!({"result":{"value":{}}})), + // Core's empty-reply decoder accepts null as success; an + // array is malformed and must leave the write uncertain. + "successNull" => Some(json!({"result":{"value":null}})), + "cancelled" => Some(json!({"error":{"code":"requestCancelled"}})), + "timeout" => Some(json!({"error":{"code":"requestTimeout"}})), + "invalidReply" => Some(json!({"result":{"value":[]}})), + _ => None, + }; + if let Some(mut event) = completion { + event["type"] = json!("requestCompleted"); + event["sessionId"] = json!("fixture-session"); + event["operationId"] = json!("fixture-request-write"); + runtime.events.lock().unwrap().push_back(vec![event]); + } + let owner = owner(context(), runtime.clone()); + owner.mark_mutation(); + let sessions = Sessions::default(); + sessions.insert(owner.clone(), || true).unwrap(); + owner.start(|| Ok((json!({}), None))).await.unwrap(); + owner.status(|| Ok(())).await.unwrap(); + let result = owner.write(&operation, move || { + if outcome == "preflight" { Err(super::super::registry::STALE.into()) } else { Ok(()) } + }, || Ok(())).await; + assert_eq!(result.is_ok(), matches!(outcome, "success" | "successNull"), "{} {outcome}", operation.name()); + assert_eq!(owner.uncertain(), matches!(outcome, "lost" | "cancelled" | "timeout" | "invalidReply"), "{} {outcome}", operation.name()); + runtime.reject_disposal.store(true, Ordering::Release); + owner.revoke(); + assert!(!owner.dispose_once().await); + let error = super::super::status::complete_request(result.clone(), Err(CLEANUP_FAILED.into()), true, owner.uncertain()).unwrap_err(); + assert_eq!(error.starts_with("vcs.provider.outcomeUnknown:"), matches!(outcome, "success" | "successNull" | "lost" | "cancelled" | "timeout" | "invalidReply"), "{} {outcome}", operation.name()); + assert!(owner.lease.lock().unwrap().is_some()); + // Another window cannot mutate the same root until disposal is proved. + let mut other_context = context(); + other_context.host = "another-window".into(); + let other = self::owner(other_context, Arc::new(ScriptedRuntime::default())); + other.mark_mutation(); + assert_eq!(sessions.insert(other.clone(), || true).unwrap_err(), CLEANUP_FAILED); + runtime.reject_disposal.store(false, Ordering::Release); + sessions.cleanup(owner.clone()).await.unwrap(); + assert!(owner.lease.lock().unwrap().is_none()); + sessions.insert(other.clone(), || true).unwrap(); + // A late old cleanup must neither remove nor stop the replacement owner. + sessions.cleanup(owner.clone()).await.unwrap(); + assert!(other.current().is_ok()); + assert!(other.lease.lock().unwrap().is_some()); + sessions.cleanup(other).await.unwrap(); + assert_eq!(runtime.calls.lock().unwrap().iter().filter(|(name, payload)| + name == "lsp.vcsRequest" && payload["operation"] == operation.request_name()).count(), usize::from(outcome != "preflight")); + } + } + }).await.expect("bounded resolve/cleanup outcomes and original owner"); +} + +#[tokio::test] +async fn replacement_preflight_cancellation_and_lost_write_reply_never_replay_side_effects() { + tokio::time::timeout(DEADLINE, async { + for preflight_rejected in [true, false] { + let runtime = Arc::new(ScriptedRuntime::default()); + runtime.events.lock().unwrap().extend([ready("svn"), vec![json!({"type":"requestCompleted","sessionId":"fixture-session","operationId":"fixture-request","result":{"value":{"changes":[]}}})]]); + let owner = owner(context(), runtime.clone()); + owner.start(|| Ok((json!({}), None))).await.unwrap(); + owner.status(|| Ok(())).await.unwrap(); + let operation = super::super::actions::WriteOperation::Rollback { paths: vec!["a.txt".into()] }; + assert!(owner.write(&operation, move || if preflight_rejected { Err("requestCancelled".into()) } else { Ok(()) }, || Ok(())).await.is_err()); + assert_eq!(owner.uncertain(), !preflight_rejected); + assert_eq!(runtime.calls.lock().unwrap().iter().filter(|(command, payload)| command == "lsp.vcsRequest" && payload["operation"] == "rollback").count(), usize::from(!preflight_rejected)); + owner.stop().await.unwrap(); + } + }).await.expect("bounded uncertain write"); +} + +#[tokio::test] +async fn definitive_write_rejections_survive_failed_destroy_and_same_owner_cleanup_retry() { + tokio::time::timeout(DEADLINE, async { + for code in ["requestCancelled", "vcs.authentication.failed", "vcs.outOfDate"] { + let runtime = Arc::new(ScriptedRuntime::default()); + runtime.events.lock().unwrap().extend([ + ready("svn"), + vec![json!({"type":"requestCompleted","sessionId":"fixture-session","operationId":"fixture-request","result":{"value":{"changes":[]}}})], + vec![json!({"type":"requestCompleted","sessionId":"fixture-session","operationId":"fixture-request-write","error":{"code":code}})], + ]); + let owner = owner(context(), runtime.clone()); + owner.mark_mutation(); + let sessions = Sessions::default(); + sessions.insert(owner.clone(), || true).unwrap(); + owner.start(|| Ok((json!({}), None))).await.unwrap(); + owner.status(|| Ok(())).await.unwrap(); + let operation = super::super::actions::WriteOperation::Checkin { + paths: vec!["a.txt".into()], message: "Selected only".into(), + }; + let result = owner.write(&operation, move || { + if code == "requestCancelled" { Err(code.into()) } else { Ok(()) } + }, || Ok(())).await; + assert_eq!(result.as_ref().unwrap_err(), code); + assert!(!owner.uncertain()); + runtime.reject_disposal.store(true, Ordering::Release); + owner.revoke(); + // Drive one controlled destroy attempt instead of waiting through + // the production cleanup retry deadline in this regression. + assert!(!owner.dispose_once().await); + let error = super::super::status::complete_request( + result.clone(), Err(CLEANUP_FAILED.into()), true, owner.uncertain(), + ).unwrap_err(); + assert_eq!(error, format!("{code};{CLEANUP_FAILED}")); + assert!(owner.lease.lock().unwrap().is_some()); + assert!(sessions.0.lock().unwrap().running.values().any(|entry| Arc::ptr_eq(entry, &owner))); + runtime.reject_disposal.store(false, Ordering::Release); + let cleaned = sessions.cleanup(owner.clone()).await; + assert_eq!(super::super::status::complete_request(result, cleaned, true, owner.uncertain()).unwrap_err(), code); + assert!(owner.lease.lock().unwrap().is_none()); + assert!(sessions.0.lock().unwrap().running.is_empty()); + let writes = runtime.calls.lock().unwrap().iter().filter(|(command, payload)| command == "lsp.vcsRequest" && payload["operation"] == "checkin").count(); + assert_eq!(writes, usize::from(code != "requestCancelled")); + } + }).await.expect("bounded definitive write cleanup retry"); +} + +#[tokio::test] +async fn successful_or_unanswered_writes_require_cleanup_proof_before_publication() { + tokio::time::timeout(DEADLINE, async { + for answered in [false, true] { + let runtime = Arc::new(ScriptedRuntime::default()); + runtime.events.lock().unwrap().extend([ + ready("svn"), + vec![json!({"type":"requestCompleted","sessionId":"fixture-session","operationId":"fixture-request","result":{"value":{"changes":[]}}})], + ]); + if answered { + runtime.events.lock().unwrap().push_back(vec![json!({"type":"requestCompleted","sessionId":"fixture-session","operationId":"fixture-request-write","result":{"value":{"revision":{"id":"r2"}}}})]); + } + let owner = owner(context(), runtime.clone()); + let sessions = Sessions::default(); + sessions.insert(owner.clone(), || true).unwrap(); + owner.start(|| Ok((json!({}), None))).await.unwrap(); + owner.status(|| Ok(())).await.unwrap(); + let operation = super::super::actions::WriteOperation::Checkin { + paths: vec!["a.txt".into()], message: "Selected only".into(), + }; + let result = owner.write(&operation, || Ok(()), || Ok(())).await; + assert_eq!(result.is_ok(), answered); + assert_eq!(owner.uncertain(), !answered); + runtime.reject_disposal.store(true, Ordering::Release); + owner.revoke(); + assert!(!owner.dispose_once().await); + let error = super::super::status::complete_request(result, Err(CLEANUP_FAILED.into()), true, owner.uncertain()).unwrap_err(); + assert!(error.starts_with("vcs.provider.outcomeUnknown:")); + assert!(error.contains(CLEANUP_FAILED)); + assert!(owner.lease.lock().unwrap().is_some()); + runtime.reject_disposal.store(false, Ordering::Release); + sessions.cleanup(owner.clone()).await.unwrap(); + assert!(owner.lease.lock().unwrap().is_none()); + assert!(sessions.0.lock().unwrap().running.is_empty()); + assert_eq!(runtime.calls.lock().unwrap().iter().filter(|(command, payload)| command == "lsp.vcsRequest" && payload["operation"] == "checkin").count(), 1); + } + }).await.expect("bounded write publication cleanup proof"); +} + +#[tokio::test] +async fn delayed_mutation_watch_callback_after_cleanup_waits_for_the_next_explicit_scan() { + tokio::time::timeout(DEADLINE, async { + let runtime = Arc::new(ScriptedRuntime::default()); + let mut negotiation = ready("svn"); + negotiation[0]["result"]["capabilities"]["update"] = json!(1); + runtime.events.lock().unwrap().extend([ + negotiation, + vec![json!({"type":"requestCompleted","sessionId":"fixture-session","operationId":"fixture-request","result":{"value":{"changes":[]}}})], + vec![json!({"type":"requestCompleted","sessionId":"fixture-session","operationId":"fixture-request-write","result":{"value":{}}})], + ]); + let mut registry = super::super::registry::Registry::default(); + let mut context = context(); + let now = std::time::Instant::now(); + let first = registry.begin(&context.host, context.scope.clone(), context.registration, now).unwrap(); + let detection: super::super::registry::Detection = serde_json::from_value(json!({ + "providers":[], "roots":[], "ambiguous":[], "unusedMappings":[], + "truncated":false, "rejected":[], "conflicts":[], + })).unwrap(); + registry.publish(&first, detection.clone(), vec![], now).unwrap(); + context.generation = first.generation; + let other = registry.begin("other-window", context.scope.clone(), context.registration, now).unwrap(); + registry.publish(&other, detection.clone(), vec![], now).unwrap(); + let sessions = Sessions::default(); + let owner = owner(context.clone(), runtime); + owner.mark_mutation(); + sessions.insert(owner.clone(), || true).unwrap(); + owner.start(|| Ok((json!({}), None))).await.unwrap(); + owner.status(|| Ok(())).await.unwrap(); + registry.begin_write(&context.host, &context.scope, context.generation).unwrap(); + owner.write(&super::super::actions::WriteOperation::Update, || Ok(()), || Ok(())).await.unwrap(); + sessions.cleanup(owner).await.unwrap(); + assert!(sessions.0.lock().unwrap().running.is_empty()); + + // Deliver the queued metadata event only after the process owner has + // gone. The original reply stays publishable; another window does not. + assert!(!registry.invalidate_generation(&context.host, &context.scope, context.generation)); + assert!(registry.capture(&context.host, &context.scope, context.generation).is_ok()); + assert!(registry.invalidate_generation("other-window", &context.scope, other.generation)); + let next = registry.begin(&context.host, context.scope.clone(), context.registration, now).unwrap(); + registry.publish(&next, detection, vec![], now).unwrap(); + assert!(!registry.invalidate_generation(&context.host, &context.scope, context.generation)); + assert!(registry.invalidate_generation(&context.host, &context.scope, next.generation)); + }).await.expect("bounded mutation refresh fence"); +} + +#[tokio::test] +async fn invalidation_after_a_valid_write_reply_still_requires_unknown_outcome_handling() { + tokio::time::timeout(DEADLINE, async { + let runtime = Arc::new(ScriptedRuntime::default()); + let mut negotiation = ready("svn"); + negotiation[0]["result"]["capabilities"]["update"] = json!(1); + runtime.events.lock().unwrap().extend([ + negotiation, + vec![json!({"type":"requestCompleted","sessionId":"fixture-session","operationId":"fixture-request","result":{"value":{"changes":[]}}})], + vec![json!({"type":"requestCompleted","sessionId":"fixture-session","operationId":"fixture-request-write","result":{"value":{"revision":{"id":"r2"}}}})], + ]); + let owner = owner(context(), runtime.clone()); + owner.start(|| Ok((json!({}), None))).await.unwrap(); + owner.status(|| Ok(())).await.unwrap(); + // Invalidate at the public decoder completion boundary, after the + // provider has acknowledged success but before final publication. + let result = owner.write(&super::super::actions::WriteOperation::Update, || Ok(()), || { + if runtime.calls.lock().unwrap().iter().any(|(command, payload)| { + command == "vcs.decodeReply" && payload["operation"] == "update" + }) { Err(CANCELLED.into()) } else { Ok(()) } + }).await; + assert_eq!(result.unwrap_err(), CANCELLED); + assert!(owner.uncertain()); + owner.stop().await.unwrap(); + assert_eq!(runtime.calls.lock().unwrap().iter().filter(|(command, payload)| { + command == "lsp.vcsRequest" && payload["operation"] == "update" + }).count(), 1); + }).await.expect("bounded reply invalidation"); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/vcs_status.rs b/windows/tauri/src-tauri/src/plugin_host/vcs_status.rs new file mode 100644 index 0000000000..559c5b35bd --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/vcs_status.rs @@ -0,0 +1,760 @@ +//! Native composition for typed requests against a detected plugin root. The +//! package's existing install approval covers its declared VCS program/tool; +//! no build consent token or plugin-function identity is accepted here. +use super::{ + live, packages, project_registration, registry, session, validate, Scope, VcsHostState, +}; +use crate::plugin_host::{launch, lifecycle, store, ScannedPackage}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::path::PathBuf; +use std::sync::Arc; +use tauri::{AppHandle, Manager, WebviewWindow}; + +struct Prepared { + context: session::Context, + expected: Vec, + package: ScannedPackage, + lease: lifecycle::NativeLease, + plan: Value, + state_directory: PathBuf, +} +struct Admission { + plugin_id: String, + revision: u64, +} +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StatusSnapshot { + request_id: String, + generation: u64, + root_id: String, + root_identity: String, + vcs: String, + result: Value, + operation: String, + capabilities: Value, +} +fn request_id(id: &str) -> Result<(), String> { + if id.is_empty() || id.len() > 128 || id.chars().any(|c| !c.is_ascii_alphanumeric() && c != '-') + { + return Err("invalidRequest".into()); + } + Ok(()) +} +fn plan( + app: &AppHandle, + package: &ScannedPackage, + scope: &Scope, + vcs: &str, + state: &std::path::Path, +) -> session::Reply { + let layers = crate::plugin_host::settings::load_layers( + app, + &package.entry.plugin_id, + &scope.workspace_root, + )?; + let response = crate::plugin_host::scan::core_execute( + "plugins.declarative.languageServerLaunchPlan", + json!({ + "manifest":package.manifest,"vcsProviderID":vcs, + "request":launch::launch_request(state,package,&scope.workspace_root,layers), + }), + )?; + if response["status"] != "ok" || response["plan"]["vcsProvider"]["vcs"] != vcs { + return Err("vcs.provider.unavailable".into()); + } + Ok(response["plan"].clone()) +} +fn settings_revision( + app: &AppHandle, + host: &str, + scope: &Scope, + plugin: &str, +) -> Result { + app.state::() + .settings_scope_revision(host, &scope.workspace_root, plugin) + .map_err(|_| registry::STALE.into()) +} +// Capture before the command's first await: a blocked preparation must not +// silently adopt new settings after a scoped reload has already completed. +fn admission( + app: &AppHandle, + host: &str, + scope: &Scope, + generation: u64, + root_id: &str, +) -> Result { + let (snapshot, _, _) = app + .state::() + .registry + .lock() + .expect("VCS registry") + .capture(host, scope, generation)?; + let root = snapshot + .detection + .roots + .iter() + .find(|root| root.id == root_id) + .ok_or(registry::STALE)?; + let plugin_id = snapshot + .detection + .providers + .iter() + .find(|provider| provider["vcs"] == root.vcs && provider["origin"]["kind"] == "plugin") + .and_then(|provider| provider["origin"]["pluginID"].as_str()) + .ok_or("vcs.provider.unavailable")? + .to_string(); + let revision = settings_revision(app, host, scope, &plugin_id)?; + Ok(Admission { + plugin_id, + revision, + }) +} +fn prepare( + app: &AppHandle, + host: &str, + scope: Scope, + generation: u64, + root_id: String, + id: String, + admission: Admission, +) -> Result { + live(app, host)?; + if settings_revision(app, host, &scope, &admission.plugin_id)? != admission.revision { + return Err(registry::STALE.into()); + } + let (snapshot, expected, registration) = app + .state::() + .registry + .lock() + .expect("VCS registry") + .capture(host, &scope, generation)?; + if project_registration(app, host, &scope)? != registration { + return Err(registry::STALE.into()); + } + let (current, _leases) = packages(app)?; + if current != expected { + return Err(registry::STALE.into()); + } + let fresh = super::redetect(app, &scope, ¤t, &snapshot)?; + if !registry::same_roots(&snapshot.detection, &fresh) { + return Err(registry::STALE.into()); + } + if snapshot.detection.truncated { + return Err("vcs.provider.incompleteRoots".into()); + } + let root = snapshot + .detection + .roots + .iter() + .find(|root| root.id == root_id) + .cloned() + .ok_or(registry::STALE)?; + let provider = snapshot + .detection + .providers + .iter() + .find(|provider| provider["vcs"] == root.vcs && provider["origin"]["kind"] == "plugin") + .ok_or("vcs.provider.unavailable")?; + let plugin_id = provider["origin"]["pluginID"] + .as_str() + .ok_or("invalidResponse")?; + if plugin_id != admission.plugin_id { + return Err(registry::STALE.into()); + } + let lease = app + .state::() + .acquire(plugin_id)?; + let package = launch::verified_package_by_id(app, plugin_id)?; + let identity = expected + .iter() + .find(|package| package.plugin_id == plugin_id) + .ok_or(registry::STALE)? + .identity + .clone(); + let state_directory = store::plugin_state_directory(app, plugin_id)?; + let plan = plan(app, &package, &scope, &root.vcs, &state_directory)?; + Ok(Prepared { + context: session::Context { + host: host.into(), + scope, + generation, + registration, + request_id: id, + root_identity: super::mappings::directory_identity(std::path::Path::new(&root.path))?, + root, + plugin_id: plugin_id.into(), + package_identity: identity, + settings_revision: admission.revision, + }, + expected, + package, + lease, + plan, + state_directory, + }) +} +fn current(app: &AppHandle, owner: &session::Owner) -> Result<(), String> { + owner.current()?; + active(app, &owner.context) +} +fn active(app: &AppHandle, context: &session::Context) -> Result<(), String> { + live(app, &context.host)?; + if settings_revision(app, &context.host, &context.scope, &context.plugin_id)? + != context.settings_revision + { + return Err(registry::STALE.into()); + } + if app + .state::() + .blocked(&context.plugin_id) + { + return Err(registry::STALE.into()); + } + app.state::() + .registry + .lock() + .expect("VCS registry") + .capture(&context.host, &context.scope, context.generation)?; + Ok(()) +} +fn unchanged( + app: &AppHandle, + context: &session::Context, + expected: &[registry::Package], + frozen_plan: &Value, +) -> Result { + active(app, context)?; + if super::mappings::directory_identity(std::path::Path::new(&context.root.path))? + != context.root_identity + { + return Err(registry::STALE.into()); + } + let (snapshot, captured, registration) = app + .state::() + .registry + .lock() + .expect("VCS registry") + .capture(&context.host, &context.scope, context.generation)?; + if captured != expected + || registration != context.registration + || project_registration(app, &context.host, &context.scope)? != registration + { + return Err(registry::STALE.into()); + } + let (now, _leases) = packages(app)?; + if now != expected + || !now.iter().any(|package| { + package.plugin_id == context.plugin_id && package.identity == context.package_identity + }) + { + return Err(registry::STALE.into()); + } + let fresh = super::redetect(app, &context.scope, &now, &snapshot)?; + if !registry::same_roots(&snapshot.detection, &fresh) { + return Err(registry::STALE.into()); + } + let package = launch::verified_package_by_id(app, &context.plugin_id)?; + let state = store::plugin_state_directory(app, &context.plugin_id)?; + if plan(app, &package, &context.scope, &context.root.vcs, &state)? != *frozen_plan { + return Err(registry::STALE.into()); + } + Ok(snapshot) +} +/// A provider reports its whole working copy. Only Core-owned paths inside +/// this window's workspace enter the projection, including both ends of moves. +pub(super) fn project_status( + core: &dyn registry::VcsCore, + snapshot: ®istry::Snapshot, + root: ®istry::Root, + mut result: Value, +) -> session::Reply { + if snapshot.detection.truncated { + return Err("vcs.provider.incompleteRoots".into()); + } + let changes = result["changes"] + .as_array() + .ok_or("plugin.vcs.invalidOutput")?; + // Core resolves the workspace before detecting roots. Match that identity, + // including Windows verbatim prefixes and workspaces opened through links. + // Only resolve the existing directory: deleted/moved status paths may no + // longer exist and must remain valid lexical paths for Core ownership. + let workspace = std::path::Path::new(&snapshot.workspace_root) + .canonicalize() + .map_err(|_| registry::STALE)?; + let relative = |path: &str| -> Option { + std::path::Path::new(&root.path) + .join(path) + .strip_prefix(&workspace) + .ok() + .and_then(|path| path.to_str()) + .map(|path| path.replace('\\', "/")) + }; + let mut paths = Vec::new(); + let indices: Vec<_> = changes + .iter() + .map(|change| { + let path = relative(change["path"].as_str()?)?; + let start = paths.len(); + paths.push(path); + if let Some(old) = change["oldPath"].as_str() { + paths.push(relative(old)?); + } + Some(start..paths.len()) + }) + .collect(); + let mut owners = Vec::new(); + for batch in paths.chunks(10_000) { + owners.extend(registry::owners(core, snapshot, batch)?.owners); + } + result["changes"] = Value::Array( + changes + .iter() + .zip(indices) + .filter_map(|(change, indices)| { + indices + .filter(|indices| { + owners[indices.clone()] + .iter() + .all(|owner| owner.root.as_deref() == Some(root.id.as_str())) + }) + .map(|_| change.clone()) + }) + .collect(), + ); + Ok(result) +} +struct RequestGuard { + sessions: Arc, + owner: Arc, + armed: bool, +} +impl Drop for RequestGuard { + fn drop(&mut self) { + if self.armed { + cleanup_later(self.sessions.clone(), self.owner.clone()); + } + } +} +pub(super) fn cleanup_later(sessions: Arc, owner: Arc) { + owner.revoke(); + tauri::async_runtime::spawn(async move { + if sessions.cleanup(owner).await.is_err() { + eprintln!("VCS cleanup retained its native package owner"); + } + }); +} + +/// Cleanup failure retains its owner, but cannot erase a definitive rejection. +/// A successful write still waits for cleanup proof before it can be published. +pub(super) fn complete_request( + result: session::Reply, + cleaned: Result<(), String>, + write: bool, + uncertain: bool, +) -> session::Reply { + match (result, cleaned) { + (Ok(value), Ok(())) => Ok(value), + (result, cleaned) => { + let awaiting_success_cleanup = write && result.is_ok() && cleaned.is_err(); + let error = match (result, cleaned) { + (Err(error), Err(cleanup)) if write => format!("{error};{cleanup}"), + (_, Err(cleanup)) => cleanup, + (Err(error), Ok(())) => error, + (Ok(_), Ok(())) => unreachable!(), + }; + Err(if uncertain || awaiting_success_cleanup { + format!("vcs.provider.outcomeUnknown:{error}") + } else { + error + }) + } + } +} + +#[tauri::command] +pub async fn declarative_plugins_vcs_status( + app: AppHandle, + caller: WebviewWindow, + scope: Scope, + generation: u64, + root_id: String, + request_id: String, +) -> Result { + read( + app, + caller, + scope, + generation, + root_id, + request_id, + super::query::ReadOperation::Status, + ) + .await +} + +pub(super) async fn read( + app: AppHandle, + caller: WebviewWindow, + scope: Scope, + generation: u64, + root_id: String, + request_id: String, + operation: super::query::ReadOperation, +) -> Result { + run( + app, caller, scope, generation, root_id, request_id, operation, None, + ) + .await +} + +pub(super) async fn run( + app: AppHandle, + caller: WebviewWindow, + scope: Scope, + generation: u64, + root_id: String, + request_id: String, + operation: super::query::ReadOperation, + write: Option<(String, super::actions::WriteOperation)>, +) -> Result { + operation.validate()?; + live(&app, caller.label())?; + validate(&scope)?; + self::request_id(&request_id)?; + let admission = admission(&app, caller.label(), &scope, generation, &root_id)?; + let worker_app = app.clone(); + let host = caller.label().to_string(); + let preparation_operation = operation.clone(); + let (prepared, claim) = tokio::time::timeout( + registry::READ_TIMEOUT, + tauri::async_runtime::spawn_blocking(move || { + let prepared = prepare( + &worker_app, + &host, + scope, + generation, + root_id, + request_id, + admission, + )?; + let (snapshot, _, _) = worker_app + .state::() + .registry + .lock() + .expect("VCS registry") + .capture(&host, &prepared.context.scope, generation)?; + super::query::paths( + &super::Core, + &snapshot, + &prepared.context.root, + &preparation_operation, + )?; + let claim = if let Some((status_id, action)) = write { + self::request_id(&status_id)?; + let published = worker_app + .state::() + .actions + .lock() + .expect("VCS actions") + .take(&host, &prepared.context.scope, &status_id)?; + if !super::actions::same_source(&published, &prepared.context) { + return Err(registry::STALE.into()); + } + let expanded = super::actions::targets( + &super::Core, + &snapshot, + &prepared.context.root, + &published, + &action, + )?; + Some((published, action, expanded)) + } else { + None + }; + Ok::<_, String>((prepared, claim)) + }), + ) + .await + .map_err(|_| "requestTimeout")? + .map_err(|_| "internalError")??; + let owner = session::Owner::new(prepared.context.clone(), prepared.lease); + if claim.is_some() { + owner.mark_mutation(); + } + let sessions = app.state::().sessions.clone(); + sessions.insert(owner.clone(), || current(&app, &owner).is_ok())?; + let mut guard = RequestGuard { + sessions: sessions.clone(), + owner: owner.clone(), + armed: true, + }; + let context = owner.context.clone(); + let expected = prepared.expected; + let frozen_plan = prepared.plan.clone(); + let worker_app = app.clone(); + let worker_owner = owner.clone(); + let worker_expected = expected.clone(); + let worker_plan = frozen_plan.clone(); + let launch_operation = operation.clone(); + let result = async { + owner + .start(move || { + let snapshot = unchanged( + &worker_app, + &worker_owner.context, + &worker_expected, + &worker_plan, + )?; + super::query::paths( + &super::Core, + &snapshot, + &worker_owner.context.root, + &launch_operation, + )?; + worker_owner.current()?; + let workspace_state = prepared.state_directory.join(store::workspace_state_key( + &worker_owner.context.scope.workspace_root, + )); + // Existing platform storage paths own writable provider state; + // installed package resources remain read-only. + launch::prepare_required_directories( + &prepared.plan, + &prepared.state_directory, + &workspace_state, + )?; + std::fs::create_dir_all(&prepared.state_directory) + .map_err(|_| "vcs.provider.failed")?; + let resolved = launch::resolve_launch( + &prepared.package, + &worker_owner.context.root.vcs, + &prepared.plan, + &prepared.state_directory, + )?; + let declaration = &prepared.plan["vcsProvider"]["tool"]; + let tool = if declaration.is_null() { + None + } else { + let tool_plan = json!({ + "launch": {"kind":"systemCommand", "commands":declaration["commands"]}, + "validationArguments":declaration["validationArguments"], + }); + Some( + launch::resolve_launch( + &prepared.package, + &worker_owner.context.root.vcs, + &tool_plan, + &prepared.state_directory, + ) + .map_err(|_| "vcs.tool.unavailable")? + .server_path, + ) + }; + let snapshot = unchanged( + &worker_app, + &worker_owner.context, + &worker_expected, + &worker_plan, + )?; + super::query::paths( + &super::Core, + &snapshot, + &worker_owner.context.root, + &launch_operation, + )?; + worker_owner.current()?; + let root_uri = + url::Url::from_directory_path(&worker_owner.context.scope.workspace_root) + .map_err(|_| "invalidRequest")?; + let payload = json!({ + "providerId":format!("vcsProvider:{}:{}:{}", worker_owner.context.plugin_id, + worker_owner.context.root.vcs, worker_owner.context.request_id), + "pluginId":worker_owner.context.plugin_id, + "executablePath":resolved.server_path, "arguments":resolved.server_args, + "environment":resolved.environment, "rootUri":root_uri.as_str(), + "workingDirectory":worker_owner.context.scope.workspace_root, + "initializationOptions":resolved.initialization_options, + "workspaceConfiguration":resolved.workspace_configuration, + "initializeTimeoutMilliseconds":12000, "requestTimeoutMilliseconds":30000, + }); + Ok((payload, tool)) + }) + .await?; + let status = owner.read(&operation, || current(&app, &owner)).await?; + if let Some((published, action, expanded)) = &claim { + if status != published.raw || owner.capabilities() != published.capabilities { + return Err(registry::STALE.into()); + } + let worker_app = app.clone(); + let worker_owner = owner.clone(); + let worker_expected = expected.clone(); + let worker_plan = frozen_plan.clone(); + let published = published.clone(); + let selection = action.clone(); + owner + .write( + expanded, + move || { + let snapshot = unchanged( + &worker_app, + &worker_owner.context, + &worker_expected, + &worker_plan, + )?; + super::actions::targets( + &super::Core, + &snapshot, + &worker_owner.context.root, + &published, + &selection, + )?; + worker_app + .state::() + .registry + .lock() + .expect("VCS registry") + .begin_write( + &worker_owner.context.host, + &worker_owner.context.scope, + worker_owner.context.generation, + )?; + worker_owner.current() + }, + || current(&app, &owner), + ) + .await + } else { + Ok(status) + } + } + .await; + let capabilities = owner.capabilities(); + let uncertain = owner.uncertain(); + let cleaned = sessions.cleanup(owner).await; + guard.armed = false; + let result = complete_request(result, cleaned, claim.is_some(), uncertain)?; + if let Some((_, action, _)) = claim { + // A successful write is still not publishable into a replacement root. + let worker_app = app.clone(); + let worker_context = context.clone(); + let final_check = async { + tokio::time::timeout( + registry::READ_TIMEOUT, + tauri::async_runtime::spawn_blocking(move || { + unchanged(&worker_app, &worker_context, &expected, &frozen_plan).map(|_| ()) + }), + ) + .await + .map_err(|_| "requestTimeout")? + .map_err(|_| "internalError")??; + active(&app, &context) + } + .await; + final_check.map_err(|error| format!("vcs.provider.outcomeUnknown:{error}"))?; + return Ok(StatusSnapshot { + request_id: context.request_id, + generation: context.generation, + root_identity: context.root_identity, + root_id: context.root.id, + vcs: context.root.vcs, + result, + operation: action.name().into(), + capabilities, + }); + } + let raw = result.clone(); + // Cleanup may wait for a late launch worker or process descendant. Re-read + // identities and ownership after that wait so it cannot publish stale data. + let worker_app = app.clone(); + let worker_context = context.clone(); + let projection_operation = operation.clone(); + let result = tokio::time::timeout( + registry::READ_TIMEOUT, + tauri::async_runtime::spawn_blocking(move || { + let snapshot = unchanged(&worker_app, &worker_context, &expected, &frozen_plan)?; + let result = super::query::project( + &super::Core, + &snapshot, + &worker_context.root, + &projection_operation, + result, + )?; + unchanged(&worker_app, &worker_context, &expected, &frozen_plan)?; + Ok::<_, String>(result) + }), + ) + .await + .map_err(|_| "requestTimeout")? + .map_err(|_| "internalError")??; + active(&app, &context)?; + if sessions.cancelled(&context) { + return Err(session::CANCELLED.into()); + } + if matches!(operation, super::query::ReadOperation::Status) { + app.state::() + .actions + .lock() + .expect("VCS actions") + .publish(super::actions::Published { + context: context.clone(), + raw, + projected: result.clone(), + capabilities: capabilities.clone(), + }); + } + Ok(StatusSnapshot { + request_id: context.request_id, + generation: context.generation, + root_identity: context.root_identity, + root_id: context.root.id, + vcs: context.root.vcs, + result, + operation: operation.name().into(), + capabilities, + }) +} + +#[cfg(test)] +#[path = "vcs_status_tests.rs"] +mod tests; + +#[tauri::command] +pub async fn declarative_plugins_vcs_cancel_status( + app: AppHandle, + caller: WebviewWindow, + scope: Scope, + request_id: String, +) -> Result<(), String> { + live(&app, caller.label())?; + validate(&scope)?; + self::request_id(&request_id)?; + let sessions = app.state::().sessions.clone(); + if let Some(owner) = sessions.get(caller.label(), &scope, &request_id)? { + sessions.cleanup(owner).await?; + } + Ok(()) +} +pub(super) async fn close_matching( + app: &AppHandle, + predicate: impl Fn(&session::Context) -> bool, +) -> Result<(), String> { + let sessions = app.state::().sessions.clone(); + let mut failure = None; + let tasks: Vec<_> = sessions + .matching(predicate) + .into_iter() + .map(|owner| { + let sessions = sessions.clone(); + tauri::async_runtime::spawn(async move { sessions.cleanup(owner).await }) + }) + .collect(); + for task in tasks { + match task.await { + Ok(Ok(())) => {} + Ok(Err(error)) => failure = Some(error), + Err(_) => failure = Some("internalError".into()), + } + } + failure.map_or(Ok(()), Err) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/vcs_status_tests.rs b/windows/tauri/src-tauri/src/plugin_host/vcs_status_tests.rs new file mode 100644 index 0000000000..2f83259310 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/vcs_status_tests.rs @@ -0,0 +1,193 @@ +use super::*; +use serde_json::json; +use std::sync::atomic::{AtomicU64, Ordering}; +static NEXT: AtomicU64 = AtomicU64::new(1); +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "lithe-vcs-status-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + for marker in [ + "legacy/.svn", + "legacy/nested/.git", + "legacy/mixed/.svn", + "legacy/mixed/.git", + "other/.svn", + ] { + std::fs::create_dir_all(root.join(marker)).unwrap(); + } + Self(root) + } + fn snapshot(&self) -> registry::Snapshot { + let manifest: Value = serde_json::from_str(include_str!( + "../../../../../shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json" + )) + .unwrap(); + let scope = Scope { + workspace_id: "fixture".into(), + workspace_root: self.0.to_string_lossy().into_owned(), + incarnation: "one".into(), + }; + let packages = [registry::Package { + plugin_id: manifest["id"].as_str().unwrap().into(), + identity: "whole-package-digest".into(), + manifest, + }]; + registry::Snapshot { + generation: 1, + mappings: None, + watch_error: None, + mapping_choices: Vec::new(), + workspace_id: scope.workspace_id.clone(), + workspace_root: scope.workspace_root.clone(), + detection: registry::detect(&super::super::Core, &scope, &packages).unwrap(), + } + } +} +impl Drop for Fixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).expect("remove VCS status fixture"); + } +} +fn decoded(changes: Value) -> Value { + let response=crate::plugin_host::scan::core_execute("vcs.decodeReply",json!({"operation":"status","capabilities":{"status":1,"baseContent":1,"checkin":1,"rollback":1},"request":{"includeIgnored":false},"reply":{"changes":changes}})).unwrap(); + assert_eq!(response["status"], "ok"); + response["result"].clone() +} + +#[test] +fn real_core_projection_excludes_nested_git_ambiguous_paths_and_cross_root_moves() { + let fixture = Fixture::new(); + let snapshot = fixture.snapshot(); + let root = snapshot + .detection + .roots + .iter() + .find(|root| root.id == "legacy") + .unwrap(); + let result=project_status(&super::super::Core,&snapshot,root,decoded(json!([ + {"path":"a.txt","status":"modified"},{"path":"nested/git.txt","status":"modified"}, + {"path":"mixed/ambiguous.txt","status":"modified"},{"path":"moved.txt","oldPath":"nested/source.txt","status":"renamed"}, + {"path":"renamed.txt","oldPath":"source.txt","status":"renamed"} + ]))).unwrap(); + assert_eq!( + result["changes"] + .as_array() + .unwrap() + .iter() + .map(|change| change["path"].as_str().unwrap()) + .collect::>(), + vec!["a.txt", "renamed.txt"] + ); + let other = snapshot + .detection + .roots + .iter() + .find(|root| root.id == "other") + .unwrap(); + assert_eq!( + project_status( + &super::super::Core, + &snapshot, + other, + decoded(json!([{"path":"a.txt","status":"added"}])) + ) + .unwrap()["changes"][0]["path"], + "a.txt" + ); +} + +#[test] +fn real_core_projection_matches_an_ordinary_workspace_path_and_keeps_deleted_paths() { + let fixture = Fixture::new(); + let snapshot = fixture.snapshot(); + let root = snapshot + .detection + .roots + .iter() + .find(|root| root.id == "legacy") + .unwrap(); + // A normal temp path and Core's canonical root have different Windows + // prefix kinds. Changes themselves need not exist on disk. + assert_eq!( + PathBuf::from(&root.path), + fixture.0.canonicalize().unwrap().join("legacy") + ); + assert!(!PathBuf::from(&root.path).join("deleted.txt").exists()); + assert!(!PathBuf::from(&root.path).join("before.txt").exists()); + let result = project_status( + &super::super::Core, + &snapshot, + root, + decoded(json!([ + {"path":"deleted.txt","status":"deleted"}, + {"path":"after.txt","oldPath":"before.txt","status":"renamed"} + ])), + ) + .unwrap(); + assert_eq!(result["changes"].as_array().unwrap().len(), 2); + assert_eq!(result["changes"][0]["path"], "after.txt"); + assert_eq!(result["changes"][0]["oldPath"], "before.txt"); + assert_eq!(result["changes"][1]["path"], "deleted.txt"); +} + +#[test] +fn enclosing_working_copy_projects_only_this_workspace_and_complete_detection() { + let fixture = Fixture::new(); + std::fs::create_dir_all(fixture.0.join(".svn")).unwrap(); + std::fs::create_dir_all(fixture.0.join("child")).unwrap(); + let mut snapshot = fixture.snapshot(); + let root = snapshot + .detection + .roots + .iter() + .find(|root| root.id.is_empty()) + .unwrap() + .clone(); + snapshot.workspace_root = fixture.0.join("child").to_string_lossy().into_owned(); + snapshot.detection.roots = vec![registry::Root { + id: "..".into(), + ..root + }]; + snapshot.detection.ambiguous.clear(); + let root = snapshot.detection.roots[0].clone(); + let result = project_status( + &super::super::Core, + &snapshot, + &root, + decoded(json!([ + {"path":"child/a.txt","status":"modified"},{"path":"sibling.txt","status":"modified"}, + {"path":"child/moved.txt","oldPath":"outside.txt","status":"renamed"} + ])), + ) + .unwrap(); + assert_eq!(result["changes"].as_array().unwrap().len(), 1); + assert_eq!(result["changes"][0]["path"], "child/a.txt"); + snapshot.detection.truncated = true; + assert_eq!( + project_status(&super::super::Core, &snapshot, &root, decoded(json!([]))).unwrap_err(), + "vcs.provider.incompleteRoots" + ); +} + +#[test] +fn core_launch_plan_rejects_a_vcs_provider_without_its_real_declared_permissions() { + let mut manifest: Value = serde_json::from_str(include_str!( + "../../../../../shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json" + )) + .unwrap(); + manifest["permissions"] = json!(["languageServer.launch"]); + let response = crate::plugin_host::scan::core_execute( + "plugins.declarative.validate", + json!({"manifest":manifest}), + ) + .unwrap(); + assert_eq!(response["status"], "rejected"); + assert!(response["error"]["message"] + .as_str() + .unwrap() + .contains("process.vcs")); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/vcs_tests.rs b/windows/tauri/src-tauri/src/plugin_host/vcs_tests.rs new file mode 100644 index 0000000000..fb1892e39c --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/vcs_tests.rs @@ -0,0 +1,304 @@ +use super::registry::*; +use super::*; +use serde_json::json; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT: AtomicU64 = AtomicU64::new(1); +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "lithe-vcs-roots-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + fn directory(&self, relative: &str) { + std::fs::create_dir_all(self.0.join(relative)).unwrap(); + } + fn scope(&self, incarnation: &str) -> Scope { + Scope { + workspace_id: "fixture-workspace".into(), + workspace_root: self.0.to_string_lossy().into_owned(), + incarnation: incarnation.into(), + } + } +} +impl Drop for Fixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).expect("remove VCS fixture"); + } +} +fn package() -> Package { + let manifest: Value = serde_json::from_str(include_str!( + "../../../../../shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json" + )) + .unwrap(); + Package { + plugin_id: manifest["id"].as_str().unwrap().into(), + identity: "fixture-approved-whole-package".into(), + manifest, + } +} +fn snapshot(scope: &Scope, detection: Detection) -> Snapshot { + Snapshot { + generation: 1, + mappings: None, + watch_error: None, + mapping_choices: Vec::new(), + workspace_id: scope.workspace_id.clone(), + workspace_root: scope.workspace_root.clone(), + detection, + } +} +fn paths(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).into()).collect() +} + +#[test] +fn real_core_detects_mixed_nested_roots_and_never_assigns_cross_root_moves() { + let fixture = Fixture::new(); + for directory in [ + ".git", + "legacy/.svn", + "legacy/nested-git/.git", + "mixed/.git", + "mixed/.svn", + "node_modules/ignored/.svn", + ] { + fixture.directory(directory); + } + let scope = fixture.scope("one"); + let detection = detect(&Core, &scope, &[package()]).unwrap(); + assert_eq!( + detection + .roots + .iter() + .map(|root| (root.id.as_str(), root.vcs.as_str())) + .collect::>(), + vec![("", "git"), ("legacy", "svn"), ("legacy/nested-git", "git")] + ); + assert_eq!(detection.ambiguous[0]["id"], "mixed"); + let state = snapshot(&scope, detection); + let owned = owners( + &Core, + &state, + &paths(&[ + "README.md", + "legacy/a.txt", + "legacy/nested-git/b.txt", + "mixed/c.txt", + ]), + ) + .unwrap(); + assert_eq!( + owned + .owners + .iter() + .map(|owner| owner.root.as_deref()) + .collect::>(), + vec![Some(""), Some("legacy"), Some("legacy/nested-git"), None] + ); + assert!(owned.common_root.is_none()); + assert_eq!( + owners(&Core, &state, &paths(&["legacy/a.txt", "legacy/b.txt"])) + .unwrap() + .common_root + .unwrap() + .id, + "legacy" + ); + assert!( + owners(&Core, &state, &paths(&["legacy/a.txt", "README.md"])) + .unwrap() + .common_root + .is_none() + ); + assert!(owners(&Core, &state, &paths(&["../escape"])).is_err()); +} + +#[test] +fn real_core_handles_enclosing_working_copies_and_excluded_plugin_manifests() { + let fixture = Fixture::new(); + fixture.directory(".svn"); + fixture.directory("project/nested/.git"); + let mut scope = fixture.scope("one"); + scope.workspace_root = fixture.0.join("project").to_string_lossy().into_owned(); + let detected = detect(&Core, &scope, &[package()]).unwrap(); + assert_eq!( + detected + .roots + .iter() + .map(|root| root.id.as_str()) + .collect::>(), + vec!["..", "nested"] + ); + let state = snapshot(&scope, detected); + assert_eq!( + owners(&Core, &state, &paths(&["src/a.txt"])) + .unwrap() + .common_root + .unwrap() + .id, + ".." + ); + // Production supplies only accepted/enabled manifests. Removing that input + // removes SVN authority while built-in Git detection stays available. + let without_plugin = detect(&Core, &scope, &[]).unwrap(); + assert!(without_plugin.roots.iter().all(|root| root.vcs == "git")); + assert!(without_plugin + .providers + .iter() + .all(|provider| provider["origin"]["kind"] == "builtIn")); +} + +#[test] +fn truncated_and_ambiguous_snapshots_never_report_a_common_action_owner() { + let fixture = Fixture::new(); + fixture.directory(".git"); + let scope = fixture.scope("one"); + let mut state = snapshot(&scope, detect(&Core, &scope, &[]).unwrap()); + state.detection.truncated = true; + let owned = owners(&Core, &state, &paths(&["a.txt", "b.txt"])).unwrap(); + assert!(!owned.complete); + assert!(owned.common_root.is_none()); + assert_eq!(owned.owners[0].root.as_deref(), Some("")); + assert!(owners(&Core, &state, &[]).unwrap().common_root.is_none()); +} + +#[test] +fn a_new_nested_or_ambiguous_root_invalidates_the_previous_core_ownership_snapshot() { + let fixture = Fixture::new(); + fixture.directory(".git"); + let scope = fixture.scope("one"); + let initial = detect(&Core, &scope, &[package()]).unwrap(); + fixture.directory("legacy/.svn"); + let nested = detect(&Core, &scope, &[package()]).unwrap(); + assert!(!same_roots(&initial, &nested)); + fixture.directory("legacy/.git"); + let ambiguous = detect(&Core, &scope, &[package()]).unwrap(); + assert!(!same_roots(&nested, &ambiguous)); + assert!(same_roots( + &ambiguous, + &detect(&Core, &scope, &[package()]).unwrap() + )); +} + +#[test] +fn native_registry_rejects_late_detection_after_close_plugin_change_timeout_or_new_scan() { + let fixture = Fixture::new(); + fixture.directory(".git"); + let scope = fixture.scope("one"); + let now = Instant::now(); + let detection = detect(&Core, &scope, &[]).unwrap(); + let mut registry = Registry::default(); + let first = registry.begin("main", scope.clone(), 7, now).unwrap(); + assert!(registry.begin("main", scope.clone(), 8, now).is_err()); + let second = registry.begin("main", scope.clone(), 7, now).unwrap(); + assert!(registry + .publish(&first, detection.clone(), vec![], now) + .is_err()); + let view = registry + .publish(&second, detection.clone(), vec![], now) + .unwrap(); + assert!(registry + .capture("workspace-other", &scope, view.generation) + .is_err()); + registry.invalidate(); + assert!(registry.capture("main", &scope, view.generation).is_err()); + assert!(registry + .publish(&second, detection.clone(), vec![], now) + .is_err()); + let late = registry.begin("main", scope.clone(), 7, now).unwrap(); + assert!(registry + .publish(&late, detection.clone(), vec![], now + READ_TIMEOUT) + .is_err()); + registry.retire("main", &scope).unwrap(); + assert!(registry.begin("main", scope.clone(), 7, now).is_err()); + let fresh = fixture.scope("two"); + let reopened = registry.begin("main", fresh.clone(), 7, now).unwrap(); + registry + .publish(&reopened, detection.clone(), vec![], now) + .unwrap(); + registry.close_window("main"); + assert!(registry + .capture("main", &fresh, reopened.generation) + .is_err()); + assert!(registry.begin("main", fresh, 7, now).is_err()); +} + +#[test] +fn closed_old_incarnation_cannot_close_a_reopened_workspace_or_another_window() { + let fixture = Fixture::new(); + let now = Instant::now(); + let old = fixture.scope("old"); + let fresh = fixture.scope("fresh"); + let detection = detect(&Core, &old, &[]).unwrap(); + let mut registry = Registry::default(); + registry.begin("main", old.clone(), 7, now).unwrap(); + let next = registry.begin("main", fresh.clone(), 7, now).unwrap(); + registry + .publish(&next, detection.clone(), vec![package()], now) + .unwrap(); + let other = registry + .begin("workspace-other", old.clone(), 7, now) + .unwrap(); + registry.publish(&other, detection, vec![], now).unwrap(); + registry.retire("main", &old).unwrap(); + assert!(registry.capture("main", &fresh, next.generation).is_ok()); + assert!(registry + .capture("workspace-other", &old, other.generation) + .is_ok()); + let mut changed_path = fresh.clone(); + changed_path.workspace_root.push_str("/different"); + assert!(registry.begin("main", changed_path, 7, now).is_err()); + assert!(registry + .begin( + "main", + fresh.clone(), + 7, + now - std::time::Duration::from_nanos(1) + ) + .is_err()); +} + +#[test] +fn passive_vcs_queries_do_not_enable_the_provider_capability() { + assert!(super::super::host_capabilities() + .get("vcsProviders") + .is_none()); + assert!(validate(&Scope { + workspace_id: "fixture".into(), + workspace_root: "relative".into(), + incarnation: "one".into() + }) + .is_err()); + assert!(serde_json::from_value::( + json!({"workspaceId":"fixture","workspaceRoot":"relative","incarnation":"one","plugins":[]}) + ) + .is_err()); +} + +#[test] +fn plugin_private_pages_cannot_invoke_the_native_vcs_snapshot_commands() { + let mut context: tauri::Context = tauri::generate_context!(); + let acl = context.runtime_authority_mut(); + let origin = tauri::ipc::Origin::Remote { + url: url::Url::parse("http://lithe-plugin.fixture.plugin/ui/index.html").unwrap(), + }; + for command in [ + "declarative_plugins_vcs_detect", + "declarative_plugins_vcs_owners", + "declarative_plugins_vcs_save_mapping", + "declarative_plugins_vcs_close", + "declarative_plugins_vcs_status", + "declarative_plugins_vcs_cancel_status", + ] { + assert!(acl + .resolve_access(command, "plugin-web-fixture", "plugin-web-fixture", &origin) + .is_none()); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/vcs_watch.rs b/windows/tauri/src-tauri/src/plugin_host/vcs_watch.rs new file mode 100644 index 0000000000..553938b502 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/vcs_watch.rs @@ -0,0 +1,266 @@ +//! Per-incarnation leases reuse the bounded project FileWatcher. Callbacks only +//! revoke a matching generation and emit one hint; they never scan or launch. +use super::{registry, Scope}; +use lithe_project::{FileChangeEmitter, FileChangeEvent, FileChangeType, FileWatcher}; +use std::{ + collections::{BTreeMap, HashMap}, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, +}; +use tauri::AppHandle; + +const MAX_ROOTS: usize = 64; +const MAX_PATHS: usize = 256; +const MAX_ANCESTORS: usize = 64; +pub(super) struct Lease { + pub scope: Scope, + generation: u64, + enabled: Arc, + _watcher: Arc, +} +impl Drop for Lease { + fn drop(&mut self) { + self.enabled.store(false, Ordering::Release); + } +} +#[derive(Default)] +pub(super) struct Watches(pub Mutex>); +impl Watches { + fn publish( + &self, + registry: &Mutex, + host: &str, + lease: Lease, + ) -> Result<(), String> { + let mut watches = self.0.lock().map_err(|_| "internalError")?; + registry.lock().map_err(|_| "internalError")?.capture( + host, + &lease.scope, + lease.generation, + )?; + watches.insert((host.into(), lease.scope.workspace_id.clone()), lease); + Ok(()) + } + pub fn remove_older(&self, host: &str, workspace_id: &str, generation: u64) { + self.0 + .lock() + .expect("VCS watches") + .retain(|(window, workspace), lease| { + window != host || workspace != workspace_id || lease.generation >= generation + }); + } + pub fn remove_generation(&self, host: &str, scope: &Scope, generation: u64) { + self.0 + .lock() + .expect("VCS watches") + .retain(|(window, _), lease| { + window != host || lease.scope != *scope || lease.generation != generation + }); + } + pub fn remove(&self, host: &str, scope: &Scope) { + self.0 + .lock() + .expect("VCS watches") + .retain(|(window, _), lease| window != host || lease.scope != *scope); + } + pub fn retire_window(&self, host: &str, revoke: impl FnOnce()) { + // Admission must close before disposal. A discovery worker that + // publishes while revocation is pending is then included in removal; + // one that arrives afterwards fails its registry capture in publish. + // The callback releases the registry lock before the watch lock is taken. + revoke(); + self.0 + .lock() + .expect("VCS watches") + .retain(|(window, _), _| window != host); + } + pub fn clear(&self) { + self.0.lock().expect("VCS watches").clear(); + } +} +#[derive(Clone)] +struct Plan { + workspace: PathBuf, + markers: Vec, + metadata: Vec, + source: PathBuf, + paths: BTreeMap, +} +fn nearest_directory(path: &Path) -> Option { + path.ancestors() + .find(|path| path.is_dir()) + .map(Path::to_path_buf) +} +fn plan(scope: &Scope, detection: ®istry::Detection, source: &str) -> Result { + if detection.roots.len() > MAX_ROOTS || detection.ambiguous.len() > MAX_ROOTS { + return Err("vcs.watch.limit".into()); + } + let workspace = Path::new(&scope.workspace_root) + .canonicalize() + .map_err(|_| registry::STALE)?; + let source = Path::new(source); + let source = if let Some(parent) = nearest_directory(source) { + parent.canonicalize().map_err(|_| "vcs.watch.failed")?.join( + source + .strip_prefix(&parent) + .map_err(|_| "vcs.watch.failed")?, + ) + } else { + return Err("vcs.watch.failed".into()); + }; + let mut plan = Plan { + workspace: workspace.clone(), + markers: Vec::new(), + metadata: Vec::new(), + source, + paths: BTreeMap::new(), + }; + plan.paths.insert(workspace.clone(), true); + for provider in &detection.providers { + for marker in provider["markers"].as_array().into_iter().flatten() { + if let Some(name) = marker["name"].as_str() { + plan.markers.push(name.into()); + } + } + } + for (index, parent) in workspace.ancestors().skip(1).enumerate() { + if index >= MAX_ANCESTORS { + return Err("vcs.watch.limit".into()); + } + plan.paths.entry(parent.to_path_buf()).or_insert(false); + } + for root in &detection.roots { + let root_path = Path::new(&root.path); + plan.paths.entry(root_path.into()).or_insert(false); + if let Some(provider) = detection + .providers + .iter() + .find(|provider| provider["vcs"] == root.vcs) + { + for path in provider["metadataPaths"] + .as_array() + .into_iter() + .flatten() + .filter_map(|value| value.as_str()) + { + let target = root_path.join(path); + // Watch the closest existing parent; a newly created metadata + // directory causes the next passive scan to refine the lease. + if let Some(parent) = target.parent().and_then(nearest_directory) { + let resolved = parent.canonicalize().map_err(|_| "vcs.watch.failed")?; + let root = root_path.canonicalize().map_err(|_| registry::STALE)?; + if !resolved.starts_with(root) { + return Err("vcs.watch.failed".into()); + } + plan.paths.entry(parent).or_insert(false); + } + if target.is_dir() { + let resolved = target.canonicalize().map_err(|_| "vcs.watch.failed")?; + if !resolved.starts_with(root_path.canonicalize().map_err(|_| registry::STALE)?) + { + return Err("vcs.watch.failed".into()); + } + plan.paths.insert(target.clone(), true); + } + plan.metadata.push(target); + } + } + } + if let Some(parent) = plan.source.parent().and_then(nearest_directory) { + plan.paths.entry(parent).or_insert(false); + } + if plan.paths.len() > MAX_PATHS { + return Err("vcs.watch.limit".into()); + } + Ok(plan) +} +fn relevant(plan: &Plan, event: &FileChangeEvent) -> bool { + let path = Path::new(&event.path); + if event.event_type == FileChangeType::Rescan { + return true; + } + if path == plan.source || plan.source.starts_with(path) { + return true; + } + if plan.metadata.iter().any(|metadata| { + path == metadata || metadata.starts_with(path) || path.starts_with(metadata) + }) { + return true; + } + if path.starts_with(&plan.workspace) { + return matches!( + event.event_type, + FileChangeType::Opened | FileChangeType::Deleted + ); + } + // Only root markers/directories are relevant outside the opened workspace. + // Never recursively watch an enclosing working copy or interpret metadata. + plan.workspace.starts_with(path) + || path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| plan.markers.iter().any(|marker| marker == name)) +} +struct Emitter { + app: AppHandle, + host: String, + scope: Scope, + generation: u64, + enabled: Arc, + plan: Plan, +} +impl FileChangeEmitter for Emitter { + fn emit_file_change(&self, event: &FileChangeEvent) { + if relevant(&self.plan, event) && self.enabled.swap(false, Ordering::AcqRel) { + super::metadata_changed(&self.app, &self.host, &self.scope, self.generation); + } + } +} +pub(super) fn install( + app: &AppHandle, + host: &str, + scope: &Scope, + generation: u64, + detection: ®istry::Detection, + source: &str, +) -> Result<(), String> { + use tauri::Manager; + let plan = plan(scope, detection, source)?; + let enabled = Arc::new(AtomicBool::new(true)); + let watcher = Arc::new(FileWatcher::new(Arc::new(Emitter { + app: app.clone(), + host: host.into(), + scope: scope.clone(), + generation, + enabled: enabled.clone(), + plan: plan.clone(), + }))); + for (path, recursive) in plan.paths { + if recursive { + tauri::async_runtime::block_on(watcher.watch_path(path.to_string_lossy().into_owned())) + .map_err(|_| "vcs.watch.failed")?; + } else { + watcher + .watch_directory_shallow(path.to_string_lossy().into_owned()) + .map_err(|_| "vcs.watch.failed")?; + } + } + let state = app.state::(); + state.watches.publish( + &state.registry, + host, + Lease { + scope: scope.clone(), + generation, + enabled, + _watcher: watcher, + }, + ) +} + +#[cfg(test)] +#[path = "vcs_watch_tests.rs"] +mod tests; diff --git a/windows/tauri/src-tauri/src/plugin_host/vcs_watch_tests.rs b/windows/tauri/src-tauri/src/plugin_host/vcs_watch_tests.rs new file mode 100644 index 0000000000..c3de254ab9 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/vcs_watch_tests.rs @@ -0,0 +1,283 @@ +use super::super::{registry::*, Core}; +use super::*; +use serde_json::Value; +use std::sync::atomic::AtomicU64; +static NEXT: AtomicU64 = AtomicU64::new(1); +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "lithe-vcs-watch-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + for marker in [ + ".svn", + "project/nested/.git", + "project/mixed/.git", + "project/mixed/.svn", + ] { + std::fs::create_dir_all(path.join(marker)).unwrap(); + } + std::fs::write(path.join(".svn/wc.db"), b"opaque fixture, never parsed").unwrap(); + Self(path) + } + fn scope(&self, incarnation: &str) -> Scope { + Scope { + workspace_id: "fixture".into(), + workspace_root: self.0.join("project").to_string_lossy().into_owned(), + incarnation: incarnation.into(), + } + } + fn detection(&self) -> Detection { + let manifest: Value = serde_json::from_str(include_str!( + "../../../../../shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json" + )) + .unwrap(); + detect( + &Core, + &self.scope("one"), + &[Package { + plugin_id: manifest["id"].as_str().unwrap().into(), + identity: "approved".into(), + manifest, + }], + ) + .unwrap() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).expect("remove watch fixture"); + } +} +fn event(path: PathBuf, event_type: FileChangeType) -> FileChangeEvent { + FileChangeEvent { + path: path.to_string_lossy().into_owned(), + event_type, + } +} + +#[test] +fn enclosing_metadata_is_watched_without_recursively_claiming_its_working_copy() { + let fixture = Fixture::new(); + let plan = plan( + &fixture.scope("one"), + &fixture.detection(), + fixture.0.join("state/choices.json").to_str().unwrap(), + ) + .unwrap(); + let canonical = fixture.0.canonicalize().unwrap(); + assert_eq!(plan.paths.get(&canonical.join("project")), Some(&true)); + assert_eq!(plan.paths.get(&canonical), Some(&false)); + assert_eq!(plan.paths.get(&canonical.join(".svn")), Some(&false)); + assert!(relevant( + &plan, + &event(canonical.join(".svn/wc.db"), FileChangeType::Reloaded) + )); + assert!(!relevant( + &plan, + &event(canonical.join("sibling/a.txt"), FileChangeType::Reloaded) + )); + assert!(relevant( + &plan, + &event(canonical.join("project/new/.svn"), FileChangeType::Opened) + )); + assert!(relevant( + &plan, + &event( + canonical.join("project/nested/.git"), + FileChangeType::Deleted + ) + )); + assert!(relevant( + &plan, + &event(canonical.join("project"), FileChangeType::Rescan) + )); +} + +#[test] +fn declared_metadata_directories_are_watched_recursively_inside_the_verified_enclosing_root() { + let fixture = Fixture::new(); + let mut detection = fixture.detection(); + detection + .providers + .iter_mut() + .find(|provider| provider["vcs"] == "svn") + .unwrap()["metadataPaths"] = serde_json::json!([".svn"]); + let plan = plan( + &fixture.scope("one"), + &detection, + fixture.0.join("state/choices.json").to_str().unwrap(), + ) + .unwrap(); + let root = fixture.0.canonicalize().unwrap(); + assert_eq!(plan.paths.get(&root), Some(&false)); + assert_eq!(plan.paths.get(&root.join(".svn")), Some(&true)); + assert!(relevant( + &plan, + &event(root.join(".svn/deep/metadata"), FileChangeType::Reloaded) + )); +} + +#[test] +fn new_and_removed_roots_use_real_core_and_invalidate_only_the_matching_generation() { + let fixture = Fixture::new(); + let scope = fixture.scope("one"); + let mut registry = Registry::default(); + let start = std::time::Instant::now(); + let ticket = registry + .begin("window-one", scope.clone(), 1, start) + .unwrap(); + let first = registry + .publish(&ticket, fixture.detection(), vec![], start) + .unwrap(); + let other = registry + .begin("window-two", scope.clone(), 2, start) + .unwrap(); + registry + .publish(&other, fixture.detection(), vec![], start) + .unwrap(); + std::fs::create_dir_all(fixture.0.join("project/new/.svn")).unwrap(); + std::fs::remove_dir_all(fixture.0.join("project/nested/.git")).unwrap(); + assert!(!same_roots(&first.detection, &fixture.detection())); + assert!(!registry.invalidate_generation( + "window-one", + &fixture.scope("reopened"), + ticket.generation + )); + assert!(registry.invalidate_generation("window-one", &scope, ticket.generation)); + assert!(!registry.invalidate_generation("window-one", &scope, ticket.generation)); + assert!(registry + .capture("window-two", &scope, other.generation) + .is_ok()); + let next = registry + .begin("window-one", scope.clone(), 1, start) + .unwrap(); + registry + .publish(&next, fixture.detection(), vec![], start) + .unwrap(); + assert!(!registry.invalidate_generation("window-one", &scope, ticket.generation)); + assert!(registry + .capture("window-one", &scope, next.generation) + .is_ok()); +} + +struct Silent; +impl FileChangeEmitter for Silent { + fn emit_file_change(&self, _: &FileChangeEvent) {} +} +fn lease(scope: Scope, enabled: Arc) -> Lease { + Lease { + scope, + generation: 1, + enabled, + _watcher: Arc::new(FileWatcher::new(Arc::new(Silent))), + } +} + +#[test] +fn close_and_plugin_revocation_release_exact_watch_leases_and_reject_old_incarnations() { + let fixture = Fixture::new(); + let watches = Watches::default(); + let enabled = Arc::new(AtomicBool::new(true)); + let other = Arc::new(AtomicBool::new(true)); + watches.0.lock().unwrap().insert( + ("one".into(), "fixture".into()), + lease(fixture.scope("new"), enabled.clone()), + ); + watches.0.lock().unwrap().insert( + ("two".into(), "fixture".into()), + lease(fixture.scope("new"), other.clone()), + ); + watches.remove("one", &fixture.scope("old")); + assert!(enabled.load(Ordering::Acquire)); + watches.remove_generation("one", &fixture.scope("new"), 0); + assert!(enabled.load(Ordering::Acquire)); + watches.remove_older("one", "fixture", 2); + assert!(!enabled.load(Ordering::Acquire)); + assert!(other.load(Ordering::Acquire)); + watches.clear(); + assert!(!other.load(Ordering::Acquire)); +} + +#[test] +fn window_retirement_rejects_or_releases_a_discovery_lease_at_both_sides_of_revocation() { + let fixture = Fixture::new(); + let registry = Mutex::new(Registry::default()); + let watches = Watches::default(); + let scope = fixture.scope("one"); + let start = std::time::Instant::now(); + let ticket = registry + .lock() + .unwrap() + .begin("one", scope.clone(), 1, start) + .unwrap(); + registry + .lock() + .unwrap() + .publish(&ticket, fixture.detection(), vec![], start) + .unwrap(); + let other_ticket = registry + .lock() + .unwrap() + .begin("two", scope.clone(), 2, start) + .unwrap(); + registry + .lock() + .unwrap() + .publish(&other_ticket, fixture.detection(), vec![], start) + .unwrap(); + let other = Arc::new(AtomicBool::new(true)); + let mut other_lease = lease(scope.clone(), other.clone()); + other_lease.generation = other_ticket.generation; + watches.publish(®istry, "two", other_lease).unwrap(); + + let late = Arc::new(AtomicBool::new(true)); + watches.retire_window("one", || { + // Deterministically gate the interleaving at revocation's entry: an + // already discovered worker finishes publishing while admission is + // still open. Removing watches before invoking this callback leaks it. + let mut pending = lease(scope.clone(), late.clone()); + pending.generation = ticket.generation; + watches.publish(®istry, "one", pending).unwrap(); + registry.lock().unwrap().close_window("one"); + }); + assert!(!late.load(Ordering::Acquire)); + assert!(other.load(Ordering::Acquire)); + assert_eq!(watches.0.lock().unwrap().len(), 1); + + // A worker completing after revocation cannot register a replacement. + let rejected = Arc::new(AtomicBool::new(true)); + let mut pending = lease(scope, rejected.clone()); + pending.generation = ticket.generation; + assert_eq!( + watches.publish(®istry, "one", pending).unwrap_err(), + STALE + ); + assert!(!rejected.load(Ordering::Acquire)); + assert!(registry + .lock() + .unwrap() + .capture("two", &fixture.scope("one"), other_ticket.generation) + .is_ok()); + watches.clear(); + assert!(!other.load(Ordering::Acquire)); +} + +#[test] +fn root_watch_budget_fails_explicitly_instead_of_silently_claiming_complete_monitoring() { + let evidence: Value = serde_json::from_str(include_str!( + "../../../../../shared/fixtures/vcs/windows-root-mappings-v1.json" + )) + .unwrap(); + assert_eq!(evidence["rules"]["maxWatchRoots"], MAX_ROOTS); + assert_eq!(evidence["rules"]["maxWatchPaths"], MAX_PATHS); + let fixture = Fixture::new(); + let mut detection = fixture.detection(); + let root = detection.roots[0].clone(); + detection.roots = vec![root; MAX_ROOTS + 1]; + assert!( + matches!(plan(&fixture.scope("one"), &detection, "ignored"), Err(error) if error == "vcs.watch.limit") + ); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/web.rs b/windows/tauri/src-tauri/src/plugin_host/web.rs index 905f791205..bd321c43f3 100644 --- a/windows/tauri/src-tauri/src/plugin_host/web.rs +++ b/windows/tauri/src-tauri/src/plugin_host/web.rs @@ -5,9 +5,8 @@ //! owning installed package are served, read-only, from a plugin-private //! origin, with a CSP that refuses network access, remote resources, frames, //! inline scripts and eval. The window shell (dock, title, order, lifecycle) -//! is host-owned; the page fills the body only. Build-system bridge methods -//! wait for the Windows build-system plugin capability and are not part of -//! this module. +//! is host-owned; the page fills the body only. The build submodule routes +//! instance reads, option edits and approved build operations through the owning workbench. use super::scan::ScannedPackage; use serde::{Deserialize, Serialize}; @@ -17,6 +16,9 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; +#[path = "web_build.rs"] +pub mod build; + #[cfg(windows)] #[path = "web_network.rs"] mod network; @@ -43,6 +45,8 @@ pub struct WebPageHostState { workspace_generations: Mutex>, open_files: Mutex>, appearances: Mutex>, + builds: build::BuildRequests, + pub(super) functions: super::functions::web::Calls, } struct PendingOpenFile { @@ -77,7 +81,7 @@ fn workspace_generation(state: &WebPageHostState, host: &str, workspace: &str) - /// Effects and revocation share the owners lock. A request cloned before /// close cannot recreate state or register an event after authority is gone. -fn with_page_owner( +pub(crate) fn with_page_owner( state: &WebPageHostState, label: &str, identity: &BridgeIdentity, @@ -86,6 +90,14 @@ fn with_page_owner( let owners = state.owners.lock().expect("page owners"); if !owners.get(label).is_some_and(|owner| { owner.generation == identity.generation + && owner.workspace_generation == identity.workspace_generation + && owner.plugin_id == identity.plugin_id + && owner.window_id == identity.window_id + && owner.host_label == identity.host_label + && owner.workspace_id == identity.workspace_id + && owner.workspace_path == identity.workspace_path + && owner.state_id == identity.state_id + && owned_label(owner) == label && owner.workspace_generation == workspace_generation(state, &owner.host_label, &owner.workspace_id) }) { @@ -109,6 +121,8 @@ fn revoke_owners( .map(|(label, _)| label.clone()) .collect(); owners.retain(|label, _| !labels.contains(label)); + state.builds.revoke_pages(&labels); + state.functions.revoke_pages(&labels); state .open_files .lock() @@ -129,6 +143,21 @@ fn revoke_owners( labels } +/// A new document retains page state but loses every old request's authority. +fn navigate_page(state: &WebPageHostState, label: &str) { + let mut owners = state.owners.lock().expect("page owners"); + if let Some(owner) = owners.get_mut(label) { + owner.generation = OPEN_PAGE_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + state.builds.revoke_pages(&[label.to_string()]); + state.functions.revoke_pages(&[label.to_string()]); + state + .open_files + .lock() + .expect("open file requests") + .retain(|_, request| request.page_label != label); + } +} + fn workspace_label_suffix(host_label: &str, workspace_id: &str) -> String { format!( "--h{}--s{}", @@ -294,11 +323,18 @@ pub async fn declarative_plugins_open_web_window( "{plugin_id}/{window_id} is not a declared web tool window" )); }; - app.state::() - .owners - .lock() - .expect("page owners") - .insert(label.clone(), owner); + { + let state = app.state::(); + let mut owners = state.owners.lock().expect("page owners"); + state.builds.revoke_pages(&[label.clone()]); + state.functions.revoke_pages(&[label.clone()]); + state + .open_files + .lock() + .expect("open file requests") + .retain(|_, request| request.page_label != label); + owners.insert(label.clone(), owner); + } app.state::() .appearances .lock() @@ -311,6 +347,8 @@ pub async fn declarative_plugins_open_web_window( let target_url = package_page_url(&window.plugin_id, &window.entry)?; let request_plugin_id = window.plugin_id.clone(); let plugin_id = window.plugin_id.clone(); + let navigation_app = app.clone(); + let navigation_label = label.clone(); // Start inert. The request filter must be installed before package code // can navigate or otherwise cause an outbound request. let view = WebviewWindowBuilder::new( @@ -320,7 +358,16 @@ pub async fn declarative_plugins_open_web_window( ) .title(window.title) .incognito(true) - .on_navigation(move |url| is_package_url(url, &plugin_id)) + .on_navigation(move |url| { + let allowed = is_package_url(url, &plugin_id); + if allowed { + navigate_page( + &navigation_app.state::(), + &navigation_label, + ); + } + allowed + }) .on_new_window(|_, _| tauri::webview::NewWindowResponse::Deny) .on_download(|_, _| false) .build() @@ -449,6 +496,13 @@ pub fn declarative_plugins_close_web_window( window_id, state_id: String::new(), }); + // Hide/close preserves saved page state, but revokes pending effects + // before the asynchronous native destruction event can arrive. + revoke_owners( + &app.state::(), + |owner| owned_label(owner) == label, + false, + ); if let Some(existing) = app.get_webview_window(&label) { existing .close() @@ -506,10 +560,13 @@ pub fn forget_plugin_state(app: &AppHandle, plugin_id: &str) { } /// The first parser-blocking script defines the frozen `window.lithe` bridge. -/// v0 methods: context, request (state + openFile), on; build.* and -/// functions.* resolve to `plugin.web.unknownMethod` until their Windows -/// capabilities land. -fn bridge_script(plugin_id: &str, window_id: &str, appearance: &WebPageAppearance) -> String { +/// Build requests remain page-owned and require the workbench's native approval. +fn bridge_script( + plugin_id: &str, + window_id: &str, + appearance: &WebPageAppearance, + generation: u64, +) -> String { let context = json!({ "pluginID": plugin_id, "windowID": window_id, @@ -521,6 +578,7 @@ fn bridge_script(plugin_id: &str, window_id: &str, appearance: &WebPageAppearanc .replace("__LITHE_APPEARANCE__", &json!(appearance).to_string()) .replace("__LITHE_PLUGIN__", &json!(plugin_id).to_string()) .replace("__LITHE_WINDOW__", &json!(window_id).to_string()) + .replace("__LITHE_PAGE_GENERATION__", &generation.to_string()) } /// Bridge bootstrap as a plain string; placeholders are replaced with JSON @@ -529,6 +587,7 @@ fn const_bridge_script() -> &'static str { r#"(() => { "use strict"; const initial = __LITHE_CONTEXT__; + Object.defineProperty(window, '__lithePageGeneration', {value: __LITHE_PAGE_GENERATION__, writable:false}); // Never expose a bridge in another document, even if native navigation // policy regresses or a page fetches this reserved script explicitly. const url = new URL(window.location.href); @@ -550,7 +609,7 @@ fn const_bridge_script() -> &'static str { const listeners = new Map(); const invoke = (method, params) => window.__TAURI_INTERNALS__.invoke( "plugin:declarative-web|request", - { method, params }, + { method, params, generation: __LITHE_PAGE_GENERATION__ }, ); const bridge = { context, @@ -669,6 +728,8 @@ fn handle_bridge_request(invoke: tauri::ipc::Invoke) { struct BridgeArgs { method: String, #[serde(default)] + generation: u64, + #[serde(default)] params: Option, } @@ -677,9 +738,20 @@ async fn bridge_dispatch( webview: &tauri::Webview, args: BridgeArgs, ) -> Result { - let method = args.method.as_str(); - let params = args.params.unwrap_or(Value::Null); + let BridgeArgs { + method, + generation, + params, + } = args; + let method = method.as_str(); + let params = params.unwrap_or(Value::Null); let identity = bridge_identity(app, webview.label())?; + if generation != 0 && generation != identity.generation { + return Err(bridge_error( + "plugin.web.forbidden", + "the originating document was revoked", + )); + } let url = webview .url() .map_err(|error| bridge_error("plugin.web.forbidden", error.to_string()))?; @@ -781,15 +853,20 @@ async fn bridge_dispatch( .map_err(|_| bridge_error("plugin.web.forbidden", "page was closed"))? .map_err(|error| bridge_error("plugin.web.openFileFailed", error))?; drop(pending); - bridge_identity(app, webview.label())?; - Ok(json!({})) + with_page_owner( + &app.state::(), + webview.label(), + &identity, + || Ok(json!({})), + ) + } + "build.instances" | "build.setOptions" | "build.refresh" | "build.runTask" + | "build.runTests" | "build.cancel" | "build.dependencies" => { + build::request(app, webview.label(), identity, method, params).await + } + "functions.list" | "functions.invoke" | "functions.cancel" => { + super::functions::web::request(app, webview.label(), identity, method, params).await } - "build.instances" | "build.refresh" | "build.runTask" | "build.runTests" - | "build.cancel" | "build.dependencies" | "build.setOptions" | "functions.list" - | "functions.invoke" | "functions.cancel" => Err(bridge_error( - "plugin.web.unknownMethod", - format!("{method} needs the Windows build-system capability"), - )), other => Err(bridge_error( "plugin.web.unknownMethod", format!("unknown method {other}"), @@ -838,6 +915,7 @@ pub fn declarative_plugins_web_open_file_ack( #[derive(Debug, Clone, PartialEq)] pub struct BridgeIdentity { pub generation: u64, + pub workspace_generation: u64, pub plugin_id: String, pub window_id: String, pub host_label: String, @@ -893,6 +971,7 @@ fn bridge_identity( } Ok(BridgeIdentity { generation: owner.generation, + workspace_generation: owner.workspace_generation, plugin_id: owner.plugin_id, window_id: owner.window_id, host_label: owner.host_label, @@ -902,6 +981,13 @@ fn bridge_identity( }) } +pub(crate) fn page_identity( + app: &AppHandle, + label: &str, +) -> Result { + bridge_identity(app, label) +} + fn declarative_windows_for(app: &AppHandle) -> Vec { let mut windows = Vec::new(); for package in app.state::().packages() { @@ -930,7 +1016,7 @@ fn hex_label_part(value: &str) -> String { .collect() } -fn bridge_error(code: &str, message: impl Into) -> String { +pub(super) fn bridge_error(code: &str, message: impl Into) -> String { json!({ "code": code, "message": message.into() }).to_string() } @@ -1237,7 +1323,13 @@ pub fn handle_scheme_request( return match appearance { Some(appearance) => ok( "text/javascript; charset=utf-8", - bridge_script(&identity.plugin_id, &identity.window_id, &appearance).into_bytes(), + bridge_script( + &identity.plugin_id, + &identity.window_id, + &appearance, + identity.generation, + ) + .into_bytes(), ), None => refused("page is no longer active"), }; @@ -1372,6 +1464,8 @@ mod tests { "plugin:event|emit", "core_execute", "read_local_file", + "declarative_plugins_web_build_handle", + "declarative_plugins_web_build_action", ] { assert!( acl.resolve_access(command, label, label, &own).is_none(), @@ -1432,6 +1526,7 @@ mod tests { theme: "dark".into(), variables: BTreeMap::new(), }, + 1, ); assert!(script.contains(r#""pluginID":"acme.php""#)); assert!(script.contains("Object.freeze(bridge)")); @@ -1725,6 +1820,7 @@ mod serve_tests { let label = owned_label(&owner); let identity = BridgeIdentity { generation: owner.generation, + workspace_generation: owner.workspace_generation, plugin_id: owner.plugin_id.clone(), window_id: owner.window_id.clone(), host_label: owner.host_label.clone(), @@ -1817,7 +1913,7 @@ mod serve_tests { #[test] fn workspace_close_revokes_a_page_created_from_an_older_workspace_epoch() { let state = WebPageHostState::default(); - let (label, identity) = registered_test_page(&state); + let (label, mut identity) = registered_test_page(&state); state .workspace_generations .lock() @@ -1831,6 +1927,10 @@ mod serve_tests { .get_mut(&label) .unwrap() .workspace_generation = 1; + // A newly obtained identity must carry the updated workspace epoch; + // the old receipt remains invalid even if the logical page reopens. + assert!(with_page_owner(&state, &label, &identity, || Ok(())).is_err()); + identity.workspace_generation = 1; assert!(with_page_owner(&state, &label, &identity, || Ok(())).is_ok()); } } diff --git a/windows/tauri/src-tauri/src/plugin_host/web_build.rs b/windows/tauri/src-tauri/src/plugin_host/web_build.rs new file mode 100644 index 0000000000..d58d8e923b --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/web_build.rs @@ -0,0 +1,725 @@ +//! Page-owned requests for the Windows build bridge. +//! The workbench supplies no build payloads: it accepts or refuses a native +//! request, and the native registry owns all model/option facts and writes. + +use super::super::build::{run_build_option_edit, run_build_task, HostCoreExecutor}; +use super::super::build_registry::{ + BuildCoreExecutor, BuildModelContext, BuildOptionsPersistence, BuildPackageSource, + BuildRegistry, BuildView, StoredBuildOptions, +}; +use super::{bridge_error, bridge_identity, with_page_owner, BridgeIdentity, WebPageHostState}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, +}; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter, Manager, WebviewWindow}; +use tokio::sync::oneshot; + +#[path = "web_build_workflow.rs"] +pub mod workflow; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_PAGE_REQUESTS: usize = 16; +const MAX_HOST_REQUESTS: usize = 128; +const MAX_PARAMETER_BYTES: usize = 64 * 1024; +static NEXT_REQUEST: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone, Debug)] +enum BuildRequest { + Instances, + SetOptions { + instance: String, + values: BTreeMap, + }, + Workflow(workflow::Request), +} + +impl BuildRequest { + fn parse(method: &str, params: Value) -> Result { + if serde_json::to_vec(¶ms) + .map_err(|e| invalid(e.to_string()))? + .len() + > MAX_PARAMETER_BYTES + { + return Err(invalid("build parameters exceed 64 KiB")); + } + match method { + "build.instances" => { + if params.is_null() || params.as_object().is_some_and(Map::is_empty) { + Ok(Self::Instances) + } else { + Err(invalid("instances accepts no parameters")) + } + } + "build.setOptions" => { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Parameters { + instance: String, + values: BTreeMap, + } + let parameters: Parameters = + serde_json::from_value(params).map_err(|e| invalid(e.to_string()))?; + if !valid_id(¶meters.instance) + || parameters.values.len() > 64 + || parameters.values.keys().any(|key| !valid_id(key)) + { + return Err(invalid( + "setOptions requires an instance and at most 64 named values", + )); + } + Ok(Self::SetOptions { + instance: parameters.instance, + values: parameters.values, + }) + } + "build.refresh" | "build.runTask" | "build.runTests" | "build.cancel" + | "build.dependencies" => Ok(Self::Workflow(workflow::Request::parse(method, params)?)), + _ => Err(bridge_error( + "plugin.web.unavailable", + "this build method is not available", + )), + } + } + + fn method(&self) -> &str { + match self { + Self::Instances => "build.instances", + Self::SetOptions { .. } => "build.setOptions", + Self::Workflow(request) => request.method(), + } + } +} + +fn valid_id(value: &str) -> bool { + !value.is_empty() && value.len() <= 1024 && !value.contains('\0') +} +fn invalid(message: impl Into) -> String { + bridge_error("plugin.web.invalidRequest", message) +} +fn forbidden() -> String { + bridge_error( + "plugin.web.forbidden", + "the page request is no longer owned", + ) +} +fn timed_out() -> String { + bridge_error("requestTimeout", "the page build request timed out") +} + +struct PendingBuild { + page_label: String, + identity: BridgeIdentity, + request: BuildRequest, + deadline: Instant, + claimed: AtomicBool, + completion: Mutex>>>, + reply: Mutex>, +} + +struct ReadyBuild { + result: Result, + generation: Option, +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BuildRequestPhase { + Accept, + Complete, + Reject, +} + +impl PendingBuild { + fn complete(&self, result: Result) { + if let Some(sender) = self.completion.lock().expect("build completion").take() { + // A closed page may have dropped its receiver; there is no second delivery. + let _ = sender.send(result); + } + } +} + +#[derive(Default)] +pub(super) struct BuildRequests( + Mutex>>, + workflow::Operations, +); + +impl BuildRequests { + fn admit(&self, pending: Arc) -> Result { + let mut requests = self.0.lock().expect("page build requests"); + if requests.len() >= MAX_HOST_REQUESTS + || requests + .values() + .filter(|p| p.page_label == pending.page_label) + .count() + >= MAX_PAGE_REQUESTS + { + return Err(invalid("too many pending page build requests")); + } + let id = format!("web-build-{}", NEXT_REQUEST.fetch_add(1, Ordering::Relaxed)); + requests.insert(id.clone(), pending); + Ok(id) + } + + fn claim(&self, id: &str, host: &str, now: Instant) -> Result, String> { + let requests = self.0.lock().expect("page build requests"); + let pending = requests.get(id).ok_or_else(forbidden)?; + if pending.identity.host_label != host { + return Err(forbidden()); + } + if now >= pending.deadline { + return Err(timed_out()); + } + if pending.claimed.swap(true, Ordering::AcqRel) { + return Err(invalid("request was already accepted")); + } + Ok(pending.clone()) + } + + fn owned(&self, id: &str, host: &str) -> Result, String> { + self.0 + .lock() + .expect("page build requests") + .get(id) + .filter(|pending| pending.identity.host_label == host) + .cloned() + .ok_or_else(forbidden) + } + + fn check(&self, id: &str, pending: &Arc, now: Instant) -> Result<(), String> { + if now >= pending.deadline { + return Err(timed_out()); + } + if !self + .0 + .lock() + .expect("page build requests") + .get(id) + .is_some_and(|p| Arc::ptr_eq(p, pending)) + { + return Err(forbidden()); + } + Ok(()) + } + + fn remove(&self, id: &str) { + self.0.lock().expect("page build requests").remove(id); + } + + pub(super) fn revoke_pages(&self, labels: &[String]) { + self.1.revoke_pages(labels); + self.0 + .lock() + .expect("page build requests") + .retain(|_, pending| { + if !labels.contains(&pending.page_label) { + return true; + } + pending.complete(Err(forbidden())); + false + }); + } +} + +struct RequestLease { + app: AppHandle, + id: String, +} +impl Drop for RequestLease { + fn drop(&mut self) { + self.app + .state::() + .builds + .1 + .request_closed(&self.id); + self.app.state::().builds.remove(&self.id); + } +} + +pub(super) async fn request( + app: &AppHandle, + page_label: &str, + identity: BridgeIdentity, + method: &str, + params: Value, +) -> Result { + let request = BuildRequest::parse(method, params)?; + let timeout = if matches!(request, BuildRequest::Workflow(_)) { + std::time::Duration::from_secs(120) + } else { + REQUEST_TIMEOUT + }; + let (completion, receiver) = oneshot::channel(); + let pending = Arc::new(PendingBuild { + page_label: page_label.into(), + identity: identity.clone(), + request, + deadline: Instant::now() + timeout, + claimed: AtomicBool::new(false), + completion: Mutex::new(Some(completion)), + reply: Mutex::new(None), + }); + let state = app.state::(); + let id = with_page_owner(&state, page_label, &identity, || { + state.builds.admit(pending.clone()) + })?; + let _lease = RequestLease { + app: app.clone(), + id: id.clone(), + }; + with_page_owner(&state, page_label, &identity, || { + app.emit_to( + &identity.host_label, + "plugin-web-build-request", + json!({ + "operationId": id, "pluginId": identity.plugin_id, "pageLabel": page_label, + "pageGeneration": identity.generation, "hostLabel": identity.host_label, + "workspaceId": identity.workspace_id, "workspacePath": identity.workspace_path, + "method": pending.request.method(), + }), + ) + .map_err(|e| bridge_error("plugin.web.unavailable", e.to_string())) + })?; + let result = async { + let value = + tokio::time::timeout_at(tokio::time::Instant::from_std(pending.deadline), receiver) + .await + .map_err(|_| timed_out())? + .map_err(|_| forbidden())??; + with_page_owner(&state, page_label, &identity, || { + state.builds.check(&id, &pending, Instant::now())?; + Ok(value) + }) + } + .await; + if result.is_err() { + state.builds.1.revoke_request(&id); + let _ = app.emit_to( + &identity.host_label, + "plugin-web-build-revoked", + json!({"operationId": id}), + ); + } + result +} + +/// Only the product window named by a pending request may accept it. There is +/// deliberately no page-supplied plugin/workspace/argv or host-supplied answer. +#[tauri::command] +pub async fn declarative_plugins_web_build_handle( + app: AppHandle, + caller: WebviewWindow, + operation_id: String, + phase: BuildRequestPhase, +) -> Result, String> { + let state = app.state::(); + let pending = if matches!(phase, BuildRequestPhase::Accept) { + state + .builds + .claim(&operation_id, caller.label(), Instant::now())? + } else { + state.builds.owned(&operation_id, caller.label())? + }; + let identity = bridge_identity(&app, &pending.page_label)?; + if identity != pending.identity { + return Err(forbidden()); + } + if matches!(phase, BuildRequestPhase::Reject) { + with_page_owner(&state, &pending.page_label, &identity, || { + pending.complete(Err(forbidden())); + state.builds.remove(&operation_id); + Ok(()) + })?; + return Ok(None); + } + if matches!(phase, BuildRequestPhase::Complete) { + return publish(app.clone(), operation_id, pending).await; + } + if matches!(pending.request, BuildRequest::Workflow(_)) { + return Ok(None); + } + let outcome = perform(app.clone(), operation_id.clone(), pending.clone()).await; + let view = match &outcome { + Ok((_, view)) => Some(view.clone()), + Err(_) => read_view(app.clone(), operation_id.clone(), pending.clone(), false) + .await + .ok() + .map(|(view, _)| view), + }; + with_page_owner(&state, &pending.page_label, &identity, || { + state + .builds + .check(&operation_id, &pending, Instant::now())?; + *pending.reply.lock().expect("build reply") = Some(ReadyBuild { + result: outcome.map(|(value, _)| value).map_err(normalize_error), + generation: view.as_ref().map(|view| view.generation), + }); + Ok(()) + })?; + Ok(view) +} + +async fn publish( + app: AppHandle, + id: String, + pending: Arc, +) -> Result, String> { + let checkpoint_app = app.clone(); + run_build_task(app, move |registry, source, _| { + with_page_owner( + &checkpoint_app.state::(), + &pending.page_label, + &pending.identity, + || { + check_request(&checkpoint_app, &id, &pending)?; + let reply = pending + .reply + .lock() + .expect("build reply") + .take() + .ok_or_else(|| invalid("the request has no completed native reply"))?; + let validity = if let Some(generation) = reply.generation { + checked_view(registry, source, &pending.identity).and_then(|(view, _)| { + if view.generation == generation { + Ok(()) + } else { + Err("plugin.build.staleContext".into()) + } + }) + } else { + Ok(()) + }; + check_request(&checkpoint_app, &id, &pending)?; + match validity { + Ok(()) => pending.complete(reply.result), + Err(error) => { + let error = normalize_error(error); + pending.complete(Err(error.clone())); + return Err(error); + } + } + Ok(()) + }, + ) + }) + .await?; + Ok(None) +} + +fn normalize_error(error: String) -> String { + if serde_json::from_str::(&error) + .ok() + .is_some_and(|v| v["code"].is_string()) + { + return error; + } + if error.starts_with("plugin.build.") { + return bridge_error(error.split(':').next().unwrap_or(&error), &error); + } + bridge_error("plugin.web.unavailable", error) +} + +fn check_request(app: &AppHandle, id: &str, pending: &Arc) -> Result<(), String> { + let state = app.state::(); + state.builds.check(id, pending, Instant::now())?; + if super::workspace_generation( + &state, + &pending.identity.host_label, + &pending.identity.workspace_id, + ) != pending.identity.workspace_generation + { + return Err(forbidden()); + } + Ok(()) +} + +async fn perform( + app: AppHandle, + id: String, + pending: Arc, +) -> Result<(Value, BuildView), String> { + let (baseline, contexts) = read_view(app.clone(), id.clone(), pending.clone(), true).await?; + let BuildRequest::SetOptions { instance, values } = &pending.request else { + return Ok(( + instances_json(&baseline, &pending.identity.plugin_id), + baseline, + )); + }; + let (selected, context) = selected_context(&baseline, &contexts, instance)?; + if values.is_empty() { + return Ok((json!({ "options": stored_options(&selected) }), baseline)); + } + let edit_app = app.clone(); + let host = pending.identity.host_label.clone(); + run_build_option_edit(app, host, move |registry, source, persistence| { + with_page_owner( + &edit_app.state::(), + &pending.page_label, + &pending.identity, + || { + check_request(&edit_app, &id, &pending)?; + let view = registry.view(&pending.identity.host_label); + if view.workspace_root != baseline.workspace_root + || view.generation != baseline.generation + 1 + || source.package(&context.plugin_id).as_ref() != Some(&context.package) + || !view.instances.contains(&selected) + { + return Err("plugin.build.staleContext".into()); + } + let BuildRequest::SetOptions { values, .. } = &pending.request else { + unreachable!() + }; + // Keep the existing native edit semantics (including explicit empty + // multi-select markers). No web option store or argv path exists. + let mut view = view; + for (option, value) in values { + check_request(&edit_app, &id, &pending)?; + let check = || check_request(&edit_app, &id, &pending); + let guarded = CheckedPersistence { + inner: persistence, + check: &check, + previous: persistence.load(&context.workspace_root)?, + last_written: Mutex::new(None), + }; + view = registry.set_option( + &HostCoreExecutor, + source, + &guarded, + &context.window_label, + &context.workspace_root, + &context.plugin_id, + &context.build_system_id, + &context.root, + option, + (!value.is_null()).then(|| value.clone()), + )?; + } + check_request(&edit_app, &id, &pending)?; + let row = view + .instances + .iter() + .find(|row| { + row["pluginID"] == context.plugin_id + && row["buildSystemID"] == context.build_system_id + && row["root"] == context.root + }) + .ok_or_else(|| "plugin.build.staleContext".to_string())?; + Ok((json!({ "options": stored_options(row) }), view)) + }, + ) + }) + .await +} + +async fn read_view( + app: AppHandle, + id: String, + pending: Arc, + validate_options: bool, +) -> Result<(BuildView, Vec), String> { + let checkpoint_app = app.clone(); + run_build_task(app, move |registry, source, _| { + with_page_owner( + &checkpoint_app.state::(), + &pending.page_label, + &pending.identity, + || { + check_request(&checkpoint_app, &id, &pending)?; + let snapshot = checked_view(registry, source, &pending.identity)?; + if validate_options { + if let BuildRequest::SetOptions { instance, values } = &pending.request { + let (selected, context) = + selected_context(&snapshot.0, &snapshot.1, instance)?; + validate_option_names(&selected, values)?; + validate_option_values(&HostCoreExecutor, &context, values)?; + } + } + Ok(snapshot) + }, + ) + }) + .await +} + +pub(crate) fn selected_context( + view: &BuildView, + contexts: &[BuildModelContext], + instance: &str, +) -> Result<(Value, BuildModelContext), String> { + let selected = view + .instances + .iter() + .find(|row| instance_id(row).as_deref() == Some(instance)) + .ok_or_else(|| { + bridge_error( + "plugin.web.forbidden", + "the instance is outside this plugin page", + ) + })?; + let context = contexts + .iter() + .find(|context| { + selected["pluginID"] == context.plugin_id + && selected["buildSystemID"] == context.build_system_id + && selected["root"] == context.root + }) + .ok_or_else(forbidden)?; + Ok((selected.clone(), context.clone())) +} + +struct CheckedPersistence<'a> { + inner: &'a dyn BuildOptionsPersistence, + check: &'a (dyn Fn() -> Result<(), String> + Sync), + previous: StoredBuildOptions, + last_written: Mutex>, +} +impl BuildOptionsPersistence for CheckedPersistence<'_> { + fn load(&self, root: &str) -> Result { + self.inner.load(root) + } + fn save(&self, root: &str, values: &StoredBuildOptions) -> Result<(), String> { + let mut last_written = self.last_written.lock().expect("guarded option write"); + if let Err(error) = (self.check)() { + // Native set_option may roll back a failed re-detection. Permit + // only restoration of this edit's original bytes, with no lost + // external update, after its deadline/revocation. This is cleanup. + if values != &self.previous || last_written.as_ref() != Some(&self.inner.load(root)?) { + return Err(error); + } + } + self.inner.save(root, values)?; + *last_written = Some(values.clone()); + Ok(()) + } +} + +pub(crate) fn checked_view( + registry: &BuildRegistry, + source: &dyn BuildPackageSource, + identity: &BridgeIdentity, +) -> Result<(BuildView, Vec), String> { + let view = registry.view(&identity.host_label); + registry.check_read_snapshot( + &identity.host_label, + &identity.workspace_path, + view.generation, + )?; + if view.workspace_root != identity.workspace_path { + return Err(bridge_error( + "plugin.web.unavailable", + "the workbench build registry is not ready for this workspace", + )); + } + let mut contexts = Vec::new(); + for row in view + .instances + .iter() + .filter(|row| row["pluginID"] == identity.plugin_id) + { + let system = row["buildSystemID"].as_str().ok_or_else(forbidden)?; + let root = row["root"].as_str().ok_or_else(forbidden)?; + let context = registry.model_context( + &identity.host_label, + &identity.workspace_path, + &identity.plugin_id, + system, + root, + )?; + if source.package(&identity.plugin_id).as_ref() != Some(&context.package) { + return Err("plugin.build.packageChanged".into()); + } + contexts.push(context); + } + Ok((view, contexts)) +} + +fn validate_option_names(row: &Value, values: &BTreeMap) -> Result<(), String> { + for key in values.keys() { + if !row["options"] + .as_array() + .is_some_and(|options| options.iter().any(|option| option["id"] == *key)) + { + return Err(invalid(format!("the instance has no option {key}"))); + } + } + Ok(()) +} + +fn validate_option_values( + core: &dyn BuildCoreExecutor, + context: &BuildModelContext, + values: &BTreeMap, +) -> Result<(), String> { + let mut candidate = context.options.clone(); + for (key, value) in values { + if value.is_null() { + candidate.remove(key); + } else { + candidate.insert(key.clone(), value.clone()); + } + } + let response = core.execute("build.resolveOptions", json!({ + "manifest": context.package.manifest, "buildSystemID": context.build_system_id, + "request": { "options": candidate, "modelValues": context.model["optionValues"].as_object().cloned().unwrap_or_default() }, + }))?; + if response["status"] != "ok" { + return Err(response["error"].to_string()); + } + if response["options"]["dropped"] + .as_array() + .into_iter() + .flatten() + .any(|key| { + key.as_str() + .is_some_and(|key| values.get(key).is_some_and(|value| !value.is_null())) + }) + { + return Err(invalid("Core rejected one or more option values")); + } + Ok(()) +} + +fn instance_id(row: &Value) -> Option { + Some( + json!([ + row["pluginID"].as_str()?, + row["buildSystemID"].as_str()?, + row["root"].as_str()? + ]) + .to_string(), + ) +} + +fn stored_options(row: &Value) -> Value { + Value::Object( + row["options"] + .as_array() + .into_iter() + .flatten() + .filter(|option| option["source"] == "user") + .filter_map(|option| Some((option["id"].as_str()?.into(), option["value"].clone()))) + .collect(), + ) +} + +fn instances_json(view: &BuildView, plugin_id: &str) -> Value { + json!({ "instances": view.instances.iter().filter(|row| row["pluginID"] == plugin_id) + .filter_map(|row| { + let mut instance = json!({ "id": instance_id(row)?, "buildSystemID": row["buildSystemID"], + "root": row["root"], "options": stored_options(row), + "state": if row["modelError"].is_object() { "failed" } else if row["model"].is_object() { "loaded" } else { "notLoaded" }, + }); + if row["model"].is_object() { instance["model"] = row["model"].clone(); } + if row["modelError"].is_object() { instance["error"] = row["modelError"].clone(); } + Some(instance) + }).collect::>() }) +} + +#[cfg(test)] +#[path = "web_build_tests.rs"] +mod tests; diff --git a/windows/tauri/src-tauri/src/plugin_host/web_build_native.rs b/windows/tauri/src-tauri/src/plugin_host/web_build_native.rs new file mode 100644 index 0000000000..d40a701005 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/web_build_native.rs @@ -0,0 +1,203 @@ +//! Thin page binding to the existing native operation owner. No plans, +//! process execution or decoding are implemented at the page boundary. +use super::*; +use std::sync::Arc; + +#[derive(Clone)] +pub(crate) struct PageOperation { + app: AppHandle, + operation: Arc, +} +impl PageOperation { + pub fn id(&self) -> &str { + &self.operation.id + } + pub fn finished(&self) -> bool { + self.operation.finished() + } + pub fn cancelled(&self) -> bool { + self.operation.cancelled() + } + pub fn context(&self) -> &super::super::build_registry::BuildModelContext { + &self.operation.context + } + pub fn revoke(&self) { + let operation = self.operation.clone(); + let owned = self.clone(); + defer_revoke( + move || operation.cancel(), + async move { owned.cancel().await }, + ); + } + pub async fn cancel(&self) -> Result<(), String> { + host::cancel( + self.app.clone(), + self.operation.context.window_label.clone(), + self.operation.id.clone(), + ) + .await + } + pub async fn execute(&self, kind: &str) -> Result { + let app = self.app.clone(); + let host = self.operation.context.window_label.clone(); + let id = self.operation.id.clone(); + match kind { + "build.refresh" => { + serde_json::to_value(host::load(app, host, id).await?).map_err(|e| e.to_string()) + } + "build.dependencies" => serde_json::to_value(dependencies::load(app, host, id).await?) + .map_err(|e| e.to_string()), + _ => serde_json::to_value(host::execute_run(app, host, id).await?) + .map_err(|e| e.to_string()), + } + } +} + +/// Page revocation can hold the page-owner and workflow-map locks. Native +/// publication takes its own mutex and then the registry, whose notifications +/// take the page-owner lock, so cancellation must start after leaving them. +fn defer_revoke( + cancel: impl FnOnce() + Send + 'static, + cleanup: impl std::future::Future> + Send + 'static, +) { + tauri::async_runtime::spawn(async move { + if let Err(error) = tauri::async_runtime::spawn_blocking(cancel).await { + eprintln!("page build cancellation worker failed: {error}"); + } + if let Err(error) = cleanup.await { + eprintln!("page build cleanup retained native owner: {error}"); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + use std::time::Duration; + + #[tokio::test] + async fn page_revocation_returns_before_waiting_for_native_publication() { + let publication = Arc::new(Mutex::new(())); + let locked = publication.lock().unwrap(); + let (started, observed) = tokio::sync::oneshot::channel(); + let (finished, completed) = tokio::sync::oneshot::channel(); + let cancelling = publication.clone(); + defer_revoke( + move || { + let _ = started.send(()); + let _publication = cancelling.lock().unwrap(); + }, + async move { + let _ = finished.send(()); + Ok(()) + }, + ); + // Simulate revocation still owning the page lock while a native + // publication is held elsewhere. Scheduling must not block this owner. + tokio::time::timeout(Duration::from_secs(2), observed) + .await + .unwrap() + .unwrap(); + drop(locked); + tokio::time::timeout(Duration::from_secs(2), completed) + .await + .unwrap() + .unwrap(); + } +} + +pub(crate) async fn prepare( + app: AppHandle, + context: &super::super::build_registry::BuildModelContext, + method: &str, + selection: Value, +) -> Result<(Value, PageOperation), String> { + let host = context.window_label.clone(); + let workspace = context.workspace_root.clone(); + let plugin = context.plugin_id.clone(); + let system = context.build_system_id.clone(); + let root = context.root.clone(); + let review = match method { + "build.refresh" => serde_json::to_value( + host::prepare_with_refresh( + app.clone(), + host.clone(), + workspace, + plugin, + system, + root, + Default::default(), + true, + ) + .await?, + ) + .map_err(|e| e.to_string())?, + "build.dependencies" => serde_json::to_value( + dependencies::prepare( + app.clone(), + host.clone(), + workspace, + plugin, + system, + root, + BuildDependenciesSelection { + project_id: selection["projectId"].as_str().map(str::to_string), + }, + ) + .await?, + ) + .map_err(|e| e.to_string())?, + _ => serde_json::to_value( + host::prepare_run( + app.clone(), + host.clone(), + workspace, + plugin, + system, + root, + serde_json::from_value(selection).map_err(|_| "plugin.build.invalidSelection")?, + ) + .await?, + ) + .map_err(|e| e.to_string())?, + }; + let id = review["operationId"] + .as_str() + .ok_or("plugin.build.invalidOutput")?; + let operation = app.state::().operations.get(&host, id)?; + Ok((review, PageOperation { app, operation })) +} + +/// Reload only the requested passive instance. A page refresh must not +/// re-detect sibling plugins, rewrite their options or cancel their runs. +pub(crate) fn refresh_passive( + registry: &mut BuildRegistry, + source: &dyn BuildPackageSource, + context: &super::super::build_registry::BuildModelContext, +) -> Result { + if source.package(&context.plugin_id).as_ref() != Some(&context.package) { + return Err("plugin.build.packageChanged".into()); + } + let before = registry.model_fingerprint(&HostCoreExecutor, context)?; + let response = host_integration::read_passive_model( + &HostCoreExecutor, + &context.package.manifest, + &context.build_system_id, + &context.workspace_root, + &context.root, + &context.options, + )?; + if response["status"] != "ok" || !response["model"].is_object() { + return Err("plugin.build.invalidOutput".into()); + } + if source.package(&context.plugin_id).as_ref() != Some(&context.package) { + return Err("plugin.build.packageChanged".into()); + } + registry.apply_command_model( + &HostCoreExecutor, + context, + response["model"].clone(), + &before, + ) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/web_build_tests.rs b/windows/tauri/src-tauri/src/plugin_host/web_build_tests.rs new file mode 100644 index 0000000000..7eb9913a47 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/web_build_tests.rs @@ -0,0 +1,844 @@ +use super::super::super::build_registry::{BuildPackageInput, StoredSystemOptions}; +use super::super::{ + navigate_page, owned_label, revoke_owners, state_key, PageOwner, PendingOpenFile, +}; +use super::*; +use std::path::PathBuf; +use std::sync::atomic::AtomicUsize; + +fn page( + state: &WebPageHostState, + host: &str, + workspace: &str, + plugin: &str, + window: &str, +) -> (String, BridgeIdentity) { + let mut owner = PageOwner { + generation: super::super::OPEN_PAGE_SEQUENCE.fetch_add(1, Ordering::Relaxed), + workspace_generation: 0, + host_label: host.into(), + workspace_id: workspace.into(), + workspace_path: format!("C:/fixture/{workspace}"), + plugin_id: plugin.into(), + window_id: window.into(), + state_id: String::new(), + }; + let label = owned_label(&owner); + owner.state_id = label.clone(); + let identity = BridgeIdentity { + generation: owner.generation, + workspace_generation: owner.workspace_generation, + plugin_id: owner.plugin_id.clone(), + window_id: owner.window_id.clone(), + host_label: owner.host_label.clone(), + workspace_id: owner.workspace_id.clone(), + workspace_path: owner.workspace_path.clone(), + state_id: label.clone(), + }; + state.owners.lock().unwrap().insert(label.clone(), owner); + (label, identity) +} + +fn pending( + label: &str, + identity: &BridgeIdentity, + now: Instant, +) -> (Arc, oneshot::Receiver>) { + let (sender, receiver) = oneshot::channel(); + ( + Arc::new(PendingBuild { + page_label: label.into(), + identity: identity.clone(), + request: BuildRequest::Instances, + deadline: now + REQUEST_TIMEOUT, + claimed: AtomicBool::new(false), + completion: Mutex::new(Some(sender)), + reply: Mutex::new(None), + }), + receiver, + ) +} + +fn code(error: &str) -> String { + serde_json::from_str::(error).unwrap()["code"] + .as_str() + .unwrap() + .into() +} + +#[test] +fn dependencies_reply_preserves_the_complete_core_tree_for_root_and_project() { + for project in ["reactor", "reactor/app"] { + let tree = json!({"project":project,"dependencies":[{"name":"library","children":[]}],"truncated":false}); + let reply = json!({"terminal":{"kind":"succeeded"},"dependencyTree":tree, + "dependencies":[],"truncated":true}); + assert_eq!(workflow::dependency_result(&reply).unwrap(), tree); + } + assert!(workflow::dependency_result(&json!({"dependencies":[],"truncated":false})).is_err()); +} + +#[test] +fn workflow_parameters_follow_existing_web_methods_and_reject_extra_authority() { + for (method, params) in [ + ("build.refresh", json!({"instance":"native-instance"})), + ( + "build.runTask", + json!({"instance":"native-instance","task":"build","project":{"name":"app","path":"."},"userArguments":["literal argument"]}), + ), + ( + "build.runTests", + json!({"instance":"native-instance","suite":"Suite","case":"case"}), + ), + ( + "build.dependencies", + json!({"instance":"native-instance","project":{"path":"nested"}}), + ), + ("build.cancel", json!({"run":"native-run"})), + ] { + let request = BuildRequest::parse(method, params).unwrap(); + assert_eq!(request.method(), method); + } + for params in [ + json!({"instance":"id","task":"build","pluginId":"foreign"}), + json!({"instance":"id","task":"build","argv":["other"]}), + json!({"instance":"id","task":"build","project":{"id":"forged"}}), + json!({"instance":"id","task":"build","userArguments":["x".repeat(4097)]}), + json!({"instance":"id","task":"build","userArguments":vec!["x";65]}), + ] { + assert!(BuildRequest::parse("build.runTask", params).is_err()); + } + assert!(BuildRequest::parse("build.cancel", json!({"run":"one","instance":"other"})).is_err()); + assert!(BuildRequest::parse( + "build.runTests", + json!({"instance":"id","userArguments":[]}) + ) + .is_err()); +} + +#[test] +fn native_failure_details_do_not_become_part_of_the_bridge_error_code() { + let error = normalize_error("plugin.build.cleanupFailed: retry the owned operation".into()); + assert_eq!(code(&error), "plugin.build.cleanupFailed"); + assert!(serde_json::from_str::(&error).unwrap()["message"] + .as_str() + .unwrap() + .contains("retry the owned operation")); +} + +#[test] +fn workflow_selection_paths_and_filters_are_bounded_and_workspace_relative() { + for selection in [ + json!({"path":"../escape"}), + json!({"path":"C:/escape"}), + json!({"path":"/escape"}), + json!({"path":"nested\\escape"}), + json!({"name":""}), + json!({}), + json!({"name":"app","tags":[""]}), + ] { + assert!(BuildRequest::parse( + "build.runTests", + json!({"instance":"id","project":selection}) + ) + .is_err()); + } + assert!(BuildRequest::parse("build.runTests", json!({"instance":"id","suite":"x\n"})).is_err()); + assert!(BuildRequest::parse( + "build.runTask", + json!({"instance":"id","task":"build","project":{"path":"nested","name":"app"}}) + ) + .is_ok()); +} + +fn workflow_context() -> (Fixture, BuildModelContext) { + let fixture = Fixture(super::super::serve_tests::unique_temp_root("web-selection")); + std::fs::write( + fixture.0.join("Cargo.toml"), + "[package]\nname='app'\nversion='0.1.0'\n", + ) + .unwrap(); + let manifest: Value = serde_json::from_str(include_str!( + "../../../../../shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json" + )) + .unwrap(); + let plugin = manifest["id"].as_str().unwrap().to_string(); + let packages = Packages(Mutex::new(BuildPackageInput { + plugin_id: plugin.clone(), + version: "0.1.0".into(), + manifest, + content_identity: "approved".into(), + })); + let root = fixture.0.to_string_lossy().into_owned(); + let mut registry = BuildRegistry::new(); + registry + .refresh( + &HostCoreExecutor, + &packages, + &MemoryOptions::default(), + "main", + &root, + ) + .unwrap(); + let mut context = registry + .model_context("main", &root, &plugin, "cargo", ".") + .unwrap(); + context.model = json!({"projects":[{"id":"root-id","name":"app","path":"."},{"id":"nested-id","name":"app","path":"nested"}], + "targets":[{"id":"test-id","projectID":"nested-id","name":"test","path":"nested","tags":["test","unit"]}], + "tasks":[{"id":"task-id","projectID":"nested-id","name":"check","path":"nested","template":"build","execution":"task","properties":{"trusted":"native"}}]}); + (fixture, context) +} +#[test] +fn web_model_selectors_resolve_exact_ids_and_full_tag_sets_without_trusting_properties() { + let (_fixture, context) = workflow_context(); + let request = workflow::Request::parse( + "build.runTask", + json!({"instance":"instance","task":"build", + "project":{"name":"app","path":"nested","properties":{"trusted":"forged"}}, + "target":{"name":"test","path":"nested","tags":["unit","test"]}, + "modelTask":{"name":"check","execution":"service","properties":{"trusted":"forged"}}}), + ) + .unwrap(); + let selection = workflow::selection(&context, &request).unwrap(); + assert_eq!(selection["projectId"], "nested-id"); + assert_eq!(selection["targetId"], "test-id"); + assert_eq!(selection["modelTaskId"], "task-id"); + assert!(selection.get("properties").is_none()); + assert_eq!(selection["kind"], "task"); +} +#[test] +fn ambiguous_cross_project_stale_and_service_model_selections_are_refused() { + let (_fixture, mut context) = workflow_context(); + for params in [ + json!({"project":{"name":"app"}}), + json!({"project":{"path":"."},"target":{"name":"test"}}), + json!({"project":{"path":"nested"},"target":{"name":"test","tags":["test"]}}), + json!({"modelTask":{"name":"missing"}}), + ] { + let mut request = json!({"instance":"instance","task":"build"}); + request + .as_object_mut() + .unwrap() + .extend(params.as_object().unwrap().clone()); + assert!(workflow::selection( + &context, + &workflow::Request::parse("build.runTask", request).unwrap() + ) + .is_err()); + } + let request = workflow::Request::parse( + "build.runTask", + json!({"instance":"instance","task":"build","modelTask":{"name":"check"}}), + ) + .unwrap(); + assert_eq!( + workflow::selection(&context, &request).unwrap()["projectId"], + "nested-id" + ); + context.model_stale = true; + assert!(workflow::selection(&context, &request).is_err()); + context.model_stale = false; + context.model["tasks"][0]["execution"] = json!("service"); + assert!(workflow::selection(&context, &request).is_err()); +} + +#[test] +fn parameters_refuse_identity_argv_unknown_fields_and_oversized_values() { + assert!(BuildRequest::parse("build.instances", Value::Null).is_ok()); + assert!(BuildRequest::parse("build.instances", json!({})).is_ok()); + for params in [ + json!({"pluginId":"foreign"}), + json!([]), + json!({"workspaceRoot":"other"}), + ] { + assert_eq!( + code(&BuildRequest::parse("build.instances", params).unwrap_err()), + "plugin.web.invalidRequest" + ); + } + assert!(BuildRequest::parse( + "build.setOptions", + json!({"instance":"id","values":{"profiles":[],"offline":null}}) + ) + .is_ok()); + for params in [ + json!({"instance":"id","values":{},"argv":["run"]}), + json!({"instance":"id","values":[],"pluginId":"foreign"}), + json!({"instance":"","values":{}}), + json!({"instance":"id","values":{"huge":"x".repeat(MAX_PARAMETER_BYTES)}}), + json!({"instance":"id","values":(0..65).map(|id| (id.to_string(), json!(true))).collect::>()}), + ] { + assert!(BuildRequest::parse("build.setOptions", params).is_err()); + } + for method in [ + "build.refresh", + "build.runTask", + "build.runTests", + "build.cancel", + "build.dependencies", + ] { + assert_eq!( + code(&BuildRequest::parse(method, json!({})).unwrap_err()), + "plugin.web.invalidRequest" + ); + } +} + +#[test] +fn foreign_host_cannot_claim_or_refuse_and_acceptance_is_single_use() { + let state = WebPageHostState::default(); + let (label, identity) = page(&state, "main", "a", "fixture.plugin", "panel"); + let now = Instant::now(); + let (request, mut receiver) = pending(&label, &identity, now); + let id = state.builds.admit(request.clone()).unwrap(); + assert!(state.builds.claim(&id, "workspace-other", now).is_err()); + assert!(state.builds.owned(&id, "workspace-other").is_err()); + assert!(!request.claimed.load(Ordering::Acquire)); + assert!(state.builds.claim(&id, "main", now).is_ok()); + assert!(state.builds.claim(&id, "main", now).is_err()); + // A host cleanup may revoke an already accepted request. + state + .builds + .owned(&id, "main") + .unwrap() + .complete(Err(forbidden())); + state.builds.remove(&id); + assert_eq!( + code(&receiver.try_recv().unwrap().unwrap_err()), + "plugin.web.forbidden" + ); + assert!(state.builds.check(&id, &request, now).is_err()); +} + +#[test] +fn every_request_including_unaccepted_requests_occupies_capacity_until_removed() { + let state = WebPageHostState::default(); + let (label, identity) = page(&state, "main", "a", "fixture.plugin", "panel"); + let now = Instant::now(); + let mut ids = Vec::new(); + for _ in 0..MAX_PAGE_REQUESTS { + let (request, _receiver) = pending(&label, &identity, now); + ids.push(state.builds.admit(request).unwrap()); + } + assert!(state + .builds + .admit(pending(&label, &identity, now).0) + .is_err()); + state.builds.remove(&ids[0]); + assert!(state + .builds + .admit(pending(&label, &identity, now).0) + .is_ok()); + for index in 1..(MAX_HOST_REQUESTS / MAX_PAGE_REQUESTS) { + let (other, identity) = page( + &state, + "main", + "a", + "fixture.plugin", + &format!("panel-{index}"), + ); + for _ in 0..MAX_PAGE_REQUESTS { + state + .builds + .admit(pending(&other, &identity, now).0) + .unwrap(); + } + } + let (other, identity) = page(&state, "main", "a", "fixture.plugin", "last"); + assert!(state + .builds + .admit(pending(&other, &identity, now).0) + .is_err()); +} + +#[test] +fn injected_deadline_rejects_queued_and_already_claimed_work_without_sleep() { + let state = WebPageHostState::default(); + let (label, identity) = page(&state, "main", "a", "fixture.plugin", "panel"); + let now = Instant::now(); + let (request, _receiver) = pending(&label, &identity, now); + let id = state.builds.admit(request.clone()).unwrap(); + assert!(state.builds.check(&id, &request, now).is_ok()); + assert_eq!( + code( + &state + .builds + .claim(&id, "main", now + REQUEST_TIMEOUT) + .err() + .unwrap() + ), + "requestTimeout" + ); + state.builds.claim(&id, "main", now).unwrap(); + assert_eq!( + code( + &state + .builds + .check(&id, &request, now + REQUEST_TIMEOUT) + .unwrap_err() + ), + "requestTimeout" + ); +} + +#[test] +fn ownership_checks_all_identity_fields_and_workspace_generation() { + let state = WebPageHostState::default(); + let (label, identity) = page(&state, "main", "a", "fixture.plugin", "panel"); + let tamper: [fn(&mut BridgeIdentity); 8] = [ + |id| id.generation += 1, + |id| id.workspace_generation += 1, + |id| id.plugin_id = "foreign.plugin".into(), + |id| id.window_id = "other".into(), + |id| id.host_label = "workspace-other".into(), + |id| id.workspace_id = "b".into(), + |id| id.workspace_path = "C:/fixture/b".into(), + |id| id.state_id = "other".into(), + ]; + for tamper in tamper { + let mut forged = identity.clone(); + tamper(&mut forged); + assert!(with_page_owner(&state, &label, &forged, || Ok(())).is_err()); + } + state + .workspace_generations + .lock() + .unwrap() + .insert(super::super::workspace_label_suffix("main", "a"), 1); + assert!(with_page_owner(&state, &label, &identity, || Ok(())).is_err()); +} + +#[test] +fn navigation_revokes_build_and_open_file_requests_but_preserves_page_state() { + let state = WebPageHostState::default(); + let (label, identity) = page(&state, "main", "a", "fixture.plugin", "panel"); + let key = state_key(&identity.plugin_id, &identity.state_id); + state + .states + .lock() + .unwrap() + .insert(key.clone(), json!({"expanded":true})); + let (request, mut receiver) = pending(&label, &identity, Instant::now()); + let id = state.builds.admit(request.clone()).unwrap(); + let (completion, mut file_receiver) = oneshot::channel(); + state.open_files.lock().unwrap().insert( + "file-operation".into(), + PendingOpenFile { + host_label: identity.host_label.clone(), + page_label: label.clone(), + completion, + }, + ); + navigate_page(&state, &label); + assert_eq!( + code(&receiver.try_recv().unwrap().unwrap_err()), + "plugin.web.forbidden" + ); + assert!(file_receiver.try_recv().is_err()); + assert!(state.open_files.lock().unwrap().is_empty()); + assert!(state.builds.check(&id, &request, Instant::now()).is_err()); + assert!(with_page_owner(&state, &label, &identity, || Ok(())).is_err()); + assert_eq!( + state.states.lock().unwrap().get(&key), + Some(&json!({"expanded":true})) + ); +} + +#[test] +fn close_and_reopen_preserve_state_without_reviving_the_previous_document() { + let state = WebPageHostState::default(); + let (label, identity) = page(&state, "main", "a", "fixture.plugin", "panel"); + let key = state_key(&identity.plugin_id, &identity.state_id); + state + .states + .lock() + .unwrap() + .insert(key.clone(), json!({"selection":"kept"})); + let (request, mut receiver) = pending(&label, &identity, Instant::now()); + let id = state.builds.admit(request).unwrap(); + revoke_owners(&state, |owner| owned_label(owner) == label, false); + assert!(receiver.try_recv().unwrap().is_err()); + let (reopened, new_identity) = page(&state, "main", "a", "fixture.plugin", "panel"); + assert_eq!(reopened, label); + assert_ne!(identity.generation, new_identity.generation); + assert!(state.builds.claim(&id, "main", Instant::now()).is_err()); + assert!(with_page_owner(&state, &label, &identity, || Ok(())).is_err()); + assert!(with_page_owner(&state, &label, &new_identity, || Ok(())).is_ok()); + assert_eq!( + state.states.lock().unwrap().get(&key), + Some(&json!({"selection":"kept"})) + ); +} + +#[test] +fn native_reply_waits_for_workbench_confirmation_and_cannot_cross_navigation() { + let state = WebPageHostState::default(); + let (label, identity) = page(&state, "main", "a", "fixture.plugin", "panel"); + let (request, mut receiver) = pending(&label, &identity, Instant::now()); + let id = state.builds.admit(request.clone()).unwrap(); + state.builds.claim(&id, "main", Instant::now()).unwrap(); + *request.reply.lock().unwrap() = Some(ReadyBuild { + result: Ok(json!({"instances":[]})), + generation: Some(1), + }); + assert!( + receiver.try_recv().is_err(), + "finishing native work must not publish before the workbench rechecks context" + ); + navigate_page(&state, &label); + assert!(with_page_owner(&state, &label, &identity, || { + request.complete(request.reply.lock().unwrap().take().unwrap().result); + Ok(()) + }) + .is_err()); + assert_eq!( + code(&receiver.try_recv().unwrap().unwrap_err()), + "plugin.web.forbidden" + ); +} + +#[test] +fn plugin_workspace_host_and_page_revocation_do_not_cross_other_owners() { + for scope in ["plugin", "workspace", "host", "page"] { + let state = WebPageHostState::default(); + let (label, identity) = page(&state, "main", "a", "fixture.plugin", "panel"); + let (other, other_identity) = page(&state, "workspace-other", "b", "other.plugin", "panel"); + let (request, mut receiver) = pending(&label, &identity, Instant::now()); + state.builds.admit(request).unwrap(); + let (other_request, mut other_receiver) = pending(&other, &other_identity, Instant::now()); + let other_id = state.builds.admit(other_request.clone()).unwrap(); + revoke_owners( + &state, + |owner| match scope { + "plugin" => owner.plugin_id == identity.plugin_id, + "workspace" => { + owner.host_label == identity.host_label + && owner.workspace_id == identity.workspace_id + } + "host" => owner.host_label == identity.host_label, + _ => owned_label(owner) == label, + }, + true, + ); + assert!(receiver.try_recv().unwrap().is_err()); + assert!(other_receiver.try_recv().is_err()); + assert!(state + .builds + .check(&other_id, &other_request, Instant::now()) + .is_ok()); + assert!(with_page_owner(&state, &other, &other_identity, || Ok(())).is_ok()); + } +} + +#[derive(Default)] +struct MemoryOptions { + value: Mutex, + writes: AtomicUsize, +} +impl BuildOptionsPersistence for MemoryOptions { + fn load(&self, _: &str) -> Result { + Ok(self.value.lock().unwrap().clone()) + } + fn save(&self, _: &str, value: &StoredBuildOptions) -> Result<(), String> { + *self.value.lock().unwrap() = value.clone(); + self.writes.fetch_add(1, Ordering::Relaxed); + Ok(()) + } +} + +fn options(value: bool) -> StoredBuildOptions { + StoredBuildOptions { + systems: BTreeMap::from([( + "cargo".into(), + StoredSystemOptions { + values: BTreeMap::from([("offline".into(), json!(value))]), + empty_markers: BTreeMap::new(), + }, + )]), + } +} + +#[test] +fn revoked_write_is_rejected_but_owned_native_rollback_remains_possible() { + let store = MemoryOptions::default(); + let active = AtomicBool::new(true); + let check = || { + if active.load(Ordering::Acquire) { + Ok(()) + } else { + Err(timed_out()) + } + }; + let guarded = CheckedPersistence { + inner: &store, + check: &check, + previous: StoredBuildOptions::default(), + last_written: Mutex::new(None), + }; + active.store(false, Ordering::Release); + assert!(guarded.save("root", &options(true)).is_err()); + assert_eq!(store.writes.load(Ordering::Relaxed), 0); + active.store(true, Ordering::Release); + guarded.save("root", &options(true)).unwrap(); + active.store(false, Ordering::Release); + assert!(guarded.save("root", &options(false)).is_err()); + guarded + .save("root", &StoredBuildOptions::default()) + .unwrap(); + assert_eq!(store.load("root").unwrap(), StoredBuildOptions::default()); + // A later external value must never be overwritten by cleanup. + store.save("root", &options(false)).unwrap(); + assert!(guarded + .save("root", &StoredBuildOptions::default()) + .is_err()); +} + +#[test] +fn page_instance_json_filters_plugins_keeps_raw_model_and_only_stored_options() { + let model = + json!({"projects":[{"id":"project-id","name":"app","path":"."}],"tasks":[],"targets":[]}); + let row = json!({"pluginID":"fixture.plugin","buildSystemID":"cargo","root":"nested", "model":model, + "options":[{"id":"offline","source":"user","value":true},{"id":"release","source":"default","value":false}]}); + let foreign = json!({"pluginID":"foreign.plugin","buildSystemID":"other","root":"."}); + let view = BuildView { + generation: 1, + workspace_root: "C:/fixture/a".into(), + instances: vec![row.clone(), foreign], + issues: vec![], + }; + let value = instances_json(&view, "fixture.plugin"); + assert_eq!(value["instances"].as_array().unwrap().len(), 1); + assert_eq!(value["instances"][0]["model"], model); + assert_eq!(value["instances"][0]["state"], "loaded"); + assert_eq!(value["instances"][0]["options"], json!({"offline":true})); + assert_ne!( + instance_id(&row), + instance_id(&json!({"pluginID":"fixture.plugin","buildSystemID":"cargo","root":"."})) + ); + assert_eq!( + code( + &validate_option_names(&row, &BTreeMap::from([("argv".into(), json!([]))])) + .unwrap_err() + ), + "plugin.web.invalidRequest" + ); +} + +struct Fixture(PathBuf); +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +struct Packages(Mutex); +impl BuildPackageSource for Packages { + fn accepted_packages(&self) -> Vec { + vec![self.0.lock().unwrap().clone()] + } + fn package(&self, id: &str) -> Option { + let package = self.0.lock().unwrap(); + (package.plugin_id == id).then(|| package.clone()) + } +} + +#[test] +fn real_core_options_persist_reset_invalidate_fingerprint_and_refuse_replaced_package() { + let fixture = Fixture(super::super::serve_tests::unique_temp_root("web-build")); + std::fs::write( + fixture.0.join("Cargo.toml"), + "[package]\nname='fixture'\nversion='0.1.0'\n[features]\nweb=[]\n", + ) + .unwrap(); + std::fs::create_dir(fixture.0.join("nested")).unwrap(); + std::fs::write( + fixture.0.join("nested/Cargo.toml"), + "[package]\nname='nested'\nversion='0.1.0'\n", + ) + .unwrap(); + let declaration: Value = serde_json::from_str(include_str!( + "../../../../../shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json" + )) + .unwrap(); + let plugin = declaration["id"].as_str().unwrap().to_string(); + let packages = Packages(Mutex::new(BuildPackageInput { + plugin_id: plugin.clone(), + version: "0.1.0".into(), + manifest: declaration, + content_identity: "original".into(), + })); + let persistence = MemoryOptions::default(); + let root = fixture.0.to_string_lossy().into_owned(); + let state = WebPageHostState::default(); + let (_, mut identity) = page(&state, "main", "a", &plugin, "panel"); + identity.workspace_path = root.clone(); + let mut registry = BuildRegistry::new(); + registry + .refresh(&HostCoreExecutor, &packages, &persistence, "main", &root) + .unwrap(); + let (first, contexts) = checked_view(®istry, &packages, &identity).unwrap(); + let nested = first + .instances + .iter() + .find(|row| row["root"] == "nested") + .expect("nested instance"); + assert_eq!( + selected_context(&first, &contexts, &instance_id(nested).unwrap()) + .unwrap() + .1 + .root, + "nested" + ); + let mut forged = nested.clone(); + forged["pluginID"] = json!("foreign.plugin"); + let mut with_foreign = first.clone(); + with_foreign.instances.push(forged.clone()); + assert!(selected_context(&with_foreign, &contexts, &instance_id(&forged).unwrap()).is_err()); + let context = &contexts[0]; + let original = first.instances[0]["optionFingerprint"].clone(); + let initial_writes = persistence.writes.load(Ordering::Relaxed); + assert!(validate_option_values( + &HostCoreExecutor, + context, + &BTreeMap::from([("offline".into(), json!("wrong type"))]) + ) + .is_err()); + assert_eq!(persistence.writes.load(Ordering::Relaxed), initial_writes); + validate_option_values( + &HostCoreExecutor, + context, + &BTreeMap::from([("offline".into(), json!(true))]), + ) + .unwrap(); + let edited = registry + .set_option( + &HostCoreExecutor, + &packages, + &persistence, + "main", + &root, + &plugin, + "cargo", + ".", + "offline", + Some(json!(true)), + ) + .unwrap(); + assert_eq!(stored_options(&edited.instances[0])["offline"], true); + assert_ne!(edited.instances[0]["optionFingerprint"], original); + let empty = registry + .set_option( + &HostCoreExecutor, + &packages, + &persistence, + "main", + &root, + &plugin, + "cargo", + ".", + "features", + Some(json!([])), + ) + .unwrap(); + assert_eq!(stored_options(&empty.instances[0])["features"], json!([])); + let reset = registry + .set_option( + &HostCoreExecutor, + &packages, + &persistence, + "main", + &root, + &plugin, + "cargo", + ".", + "offline", + None, + ) + .unwrap(); + assert!(stored_options(&reset.instances[0]).get("offline").is_none()); + registry.suspend_execution("main"); + assert!(checked_view(®istry, &packages, &identity).is_err()); + registry.resume_execution("main"); + assert!(checked_view(®istry, &packages, &identity).is_ok()); + let refresh = registry.reserve_refresh("main", &root); + assert!(checked_view(®istry, &packages, &identity).is_err()); + registry + .refresh_reserved( + &HostCoreExecutor, + &packages, + &persistence, + &refresh, + &|| Ok(()), + ) + .unwrap(); + assert!(checked_view(®istry, &packages, &identity).is_ok()); + packages.0.lock().unwrap().content_identity = "same-version-replacement".into(); + assert!(checked_view(®istry, &packages, &identity).is_err()); + registry.close_workspace("main", &root); + assert!(checked_view(®istry, &packages, &identity).is_err()); +} + +#[test] +fn passive_page_refresh_reloads_only_the_selected_instance_and_rejects_stale_context() { + let fixture = Fixture(super::super::serve_tests::unique_temp_root( + "web-passive-refresh", + )); + let cargo = |name: &str| format!("[package]\nname='{name}'\nversion='0.1.0'\n"); + std::fs::write(fixture.0.join("Cargo.toml"), cargo("root_before")).unwrap(); + std::fs::create_dir(fixture.0.join("nested")).unwrap(); + std::fs::write(fixture.0.join("nested/Cargo.toml"), cargo("nested_before")).unwrap(); + let manifest: Value = serde_json::from_str(include_str!( + "../../../../../shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json" + )) + .unwrap(); + let plugin = manifest["id"].as_str().unwrap().to_string(); + let packages = Packages(Mutex::new(BuildPackageInput { + plugin_id: plugin.clone(), + version: "0.1.0".into(), + manifest, + content_identity: "approved".into(), + })); + let mut registry = BuildRegistry::new(); + let root = fixture.0.to_string_lossy().into_owned(); + let original = registry + .refresh( + &HostCoreExecutor, + &packages, + &MemoryOptions::default(), + "main", + &root, + ) + .unwrap(); + let context = registry + .model_context("main", &root, &plugin, "cargo", "nested") + .unwrap(); + // Both files change, but the page only requested the nested instance. + std::fs::write(fixture.0.join("Cargo.toml"), cargo("root_after")).unwrap(); + std::fs::write(fixture.0.join("nested/Cargo.toml"), cargo("nested_after")).unwrap(); + let refreshed = + super::super::super::build::page::refresh_passive(&mut registry, &packages, &context) + .unwrap(); + assert_eq!(refreshed.generation, original.generation); + assert_eq!( + refreshed.instances.iter().find(|row| row["root"] == "."), + original.instances.iter().find(|row| row["root"] == ".") + ); + assert_eq!( + refreshed + .instances + .iter() + .find(|row| row["root"] == "nested") + .unwrap()["model"]["projects"][0]["name"], + "nested_after" + ); + assert!( + super::super::super::build::page::refresh_passive(&mut registry, &packages, &context) + .is_err() + ); + let current = registry + .model_context("main", &root, &plugin, "cargo", "nested") + .unwrap(); + packages.0.lock().unwrap().content_identity = "replaced".into(); + assert!( + super::super::super::build::page::refresh_passive(&mut registry, &packages, ¤t) + .is_err() + ); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/web_build_workflow.rs b/windows/tauri/src-tauri/src/plugin_host/web_build_workflow.rs new file mode 100644 index 0000000000..6cb06037e8 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/web_build_workflow.rs @@ -0,0 +1,768 @@ +//! Translate existing web selections into native IDs and bind the workbench's +//! orchestration to the page. The native build owner still prepares, executes, +//! decodes and cancels every operation. +use super::super::super::build::page::PageOperation; +use super::*; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Selection { + name: Option, + path: Option, + tags: Option>, + execution: Option, + properties: Option, +} +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Parameters { + instance: Option, + task: Option, + project: Option, + target: Option, + model_task: Option, + suite: Option, + case: Option, + #[serde(default)] + user_arguments: Vec, + run: Option, +} +#[derive(Clone, Debug)] +pub(super) struct Request { + method: String, + parameters: Parameters, +} +impl Request { + pub fn method(&self) -> &str { + &self.method + } + pub fn parse(method: &str, params: Value) -> Result { + let allowed: &[&str] = match method { + "build.refresh" => &["instance"], + "build.runTask" => &[ + "instance", + "task", + "project", + "target", + "modelTask", + "userArguments", + ], + "build.runTests" => &["instance", "project", "target", "suite", "case"], + "build.dependencies" => &["instance", "project"], + "build.cancel" => &["run"], + _ => return Err(invalid("unknown build method")), + }; + let object = params + .as_object() + .ok_or_else(|| invalid("parameters must be an object"))?; + if object.keys().any(|key| !allowed.contains(&key.as_str())) { + return Err(invalid("unknown build parameter")); + } + let parameters: Parameters = + serde_json::from_value(params).map_err(|e| invalid(e.to_string()))?; + let required = if method == "build.cancel" { + ¶meters.run + } else { + ¶meters.instance + }; + if !required + .as_deref() + .is_some_and(|value| printable(value, 1024)) + { + return Err(invalid("missing instance or run")); + } + if method == "build.runTask" + && !parameters + .task + .as_deref() + .is_some_and(|id| printable(id, 256)) + { + return Err(invalid("task is required")); + } + for value in [¶meters.suite, ¶meters.case].into_iter().flatten() { + if !printable(value, 1024) { + return Err(invalid("invalid test filter")); + } + } + if parameters.user_arguments.len() > 64 + || parameters + .user_arguments + .iter() + .any(|value| value.len() > 4096 || value.contains('\0')) + { + return Err(invalid("userArguments exceeds its budget")); + } + for selection in [ + ¶meters.project, + ¶meters.target, + ¶meters.model_task, + ] + .into_iter() + .flatten() + { + selection.validate()?; + } + Ok(Self { + method: method.into(), + parameters, + }) + } +} +fn printable(value: &str, limit: usize) -> bool { + !value.is_empty() && value.len() <= limit && !value.chars().any(char::is_control) +} +impl Selection { + fn validate(&self) -> Result<(), String> { + if self.name.is_none() && self.path.is_none() { + return Err(invalid("a selection needs name or path")); + } + if self.name.as_deref().is_some_and(|s| !printable(s, 1024)) + || self.path.as_deref().is_some_and(|s| !printable(s, 4096)) + { + return Err(invalid("invalid selection name/path")); + } + if self.path.as_deref().is_some_and(|s| { + s.starts_with('/') + || s.contains('\\') + || s.contains(':') + || s.split('/').any(|s| s == "..") + }) { + return Err(forbidden()); + } + if self + .tags + .as_ref() + .is_some_and(|tags| tags.len() > 64 || tags.iter().any(|s| !printable(s, 64))) + { + return Err(invalid("invalid selection tags")); + } + // Compatibility fields are never used as authority; native model + // properties/execution are rebuilt from the matched entity. + let _ = (&self.execution, &self.properties); + Ok(()) + } + fn matches(&self, row: &Value) -> bool { + self.name.as_deref().is_none_or(|s| row["name"] == s) + && self.path.as_deref().is_none_or(|s| row["path"] == s) + && self.tags.as_ref().is_none_or(|tags| { + let expected: std::collections::BTreeSet<_> = + tags.iter().map(String::as_str).collect(); + let actual: std::collections::BTreeSet<_> = row["tags"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + actual == expected + }) + } +} +fn match_entity( + model: &Value, + field: &str, + selection: &Option, +) -> Result, String> { + let Some(selection) = selection else { + return Ok(None); + }; + let rows = model[field] + .as_array() + .ok_or("plugin.build.modelReloadRequired")?; + let mut matches = rows.iter().filter(|row| selection.matches(row)); + let row = matches.next().ok_or("plugin.build.invalidSelection")?; + if matches.next().is_some() { + return Err("plugin.build.invalidSelection".into()); + } + Ok(Some(row.clone())) +} +pub(super) fn selection(context: &BuildModelContext, request: &Request) -> Result { + let params = &request.parameters; + if context.model_stale + && [¶ms.project, ¶ms.target, ¶ms.model_task] + .iter() + .any(|s| s.is_some()) + { + return Err("plugin.build.modelReloadRequired".into()); + } + let model_task = match_entity(&context.model, "tasks", ¶ms.model_task)?; + let mut project = match_entity(&context.model, "projects", ¶ms.project)?; + let target = match_entity(&context.model, "targets", ¶ms.target)?; + if let Some(task) = &model_task { + if task["execution"] != "task" || task["template"].as_str() != params.task.as_deref() { + return Err("plugin.build.invalidSelection".into()); + } + if project.is_none() { + project = context.model["projects"] + .as_array() + .into_iter() + .flatten() + .find(|row| row["id"] == task["projectID"]) + .cloned(); + } + } + for row in [&target, &model_task].into_iter().flatten() { + if !project + .as_ref() + .is_some_and(|p| p["id"] == row["projectID"]) + { + return Err("plugin.build.invalidSelection".into()); + } + } + let id = |row: Option| row.and_then(|row| row["id"].as_str().map(str::to_string)); + Ok( + json!({"kind":if request.method == "build.runTests" { "test" } else if request.method == "build.dependencies" { "dependencies" } else { "task" }, + "taskId":params.task,"projectId":id(project),"targetId":id(target),"modelTaskId":id(model_task), + "suite":params.suite,"case":params.case,"userArguments":params.user_arguments}), + ) +} + +struct Work { + app: AppHandle, + page: String, + identity: BridgeIdentity, + instance: String, + request: Request, + initial_package: super::super::super::build_registry::BuildPackageInput, + operation: Mutex>, + result: Mutex>, + preparing: AtomicBool, + run_delivered: AtomicBool, + executing: AtomicBool, + request_closed: AtomicBool, + terminal_delivered: AtomicBool, + execution_settled: AtomicBool, +} +#[derive(Default)] +pub(super) struct Operations( + Mutex>>, + Mutex>, +); +impl Operations { + pub fn request_closed(&self, id: &str) { + let mut rows = self.0.lock().expect("page build operations"); + if let Some(work) = rows.get(id) { + work.request_closed.store(true, Ordering::Release); + if !work.run_delivered.load(Ordering::Acquire) { + if let Some(operation) = work.operation.lock().expect("page operation").as_ref() { + if !operation.finished() { + operation.revoke(); + } + } + let unprepared = work.operation.lock().expect("page operation").is_none(); + if unprepared { + rows.remove(id); + } + } + } + } + pub fn revoke_pages(&self, labels: &[String]) { + for (id, work) in self.0.lock().expect("page build operations").iter() { + if labels.contains(&work.page) { + if let Some(operation) = work.operation.lock().expect("page operation").as_ref() { + operation.revoke(); + } + let _ = work.app.emit_to( + &work.identity.host_label, + "plugin-web-build-revoked", + json!({"operationId":id}), + ); + } + } + self.1 + .lock() + .expect("page build notifications") + .retain(|label, _| !labels.contains(label)); + } + pub fn revoke_request(&self, id: &str) { + if let Some(work) = self.0.lock().expect("page build operations").get(id) { + if let Some(operation) = work.operation.lock().expect("page operation").as_ref() { + operation.revoke(); + } + } + } + fn insert(&self, id: &str, work: Arc) -> Result<(), String> { + let mut rows = self.0.lock().expect("page build operations"); + rows.retain(|_, work| { + !work.request_closed.load(Ordering::Acquire) + || !work + .operation + .lock() + .expect("page operation") + .as_ref() + .is_some_and(PageOperation::finished) + }); + if rows.len() >= MAX_HOST_REQUESTS + || rows + .values() + .filter(|row| row.page == work.page && row.identity == work.identity) + .count() + >= MAX_PAGE_REQUESTS + { + return Err("plugin.build.busy".into()); + } + if rows.contains_key(id) { + return Err(invalid("workflow already inspected")); + } + rows.insert(id.into(), work); + Ok(()) + } + fn get(&self, id: &str, host: &str) -> Result, String> { + self.0 + .lock() + .expect("page build operations") + .get(id) + .filter(|work| work.identity.host_label == host) + .cloned() + .ok_or_else(forbidden) + } +} +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Phase { + Inspect, + Prepare, + Execute, + Finish, + Cancel, + StaticRefresh, +} + +#[tauri::command] +pub async fn declarative_plugins_web_build_action( + app: AppHandle, + caller: WebviewWindow, + operation_id: String, + phase: Phase, +) -> Result { + let host = caller.label().to_string(); + let result = dispatch(app.clone(), caller, operation_id.clone(), phase).await; + if let Err(error) = &result { + if let Ok(pending) = app + .state::() + .builds + .owned(&operation_id, &host) + { + pending.complete(Err(normalize_error(error.clone()))); + } + } + result +} +async fn dispatch( + app: AppHandle, + caller: WebviewWindow, + operation_id: String, + phase: Phase, +) -> Result { + let state = app.state::(); + if matches!(phase, Phase::Inspect) { + let pending = state.builds.owned(&operation_id, caller.label())?; + let BuildRequest::Workflow(request) = &pending.request else { + return Err(invalid("not a workflow request")); + }; + if request.method == "build.cancel" { + let run = request.parameters.run.as_deref().expect("validated run"); + let work = state + .builds + .1 + .0 + .lock() + .expect("page build operations") + .values() + .find(|work| { + work.identity == pending.identity + && work.page == pending.page_label + && work + .operation + .lock() + .expect("page operation") + .as_ref() + .is_some_and(|op| op.id() == run) + }) + .cloned() + .ok_or_else(forbidden)?; + let operation = work + .operation + .lock() + .expect("page operation") + .clone() + .ok_or_else(forbidden)?; + operation.cancel().await?; + emit_cancelled(&work, &operation); + with_page_owner(&state, &pending.page_label, &pending.identity, || { + check_request(&app, &operation_id, &pending)?; + pending.complete(Ok(json!({}))); + Ok(json!({"cancelled":true})) + }) + } else { + let (view, contexts) = + read_view(app.clone(), operation_id.clone(), pending.clone(), false).await?; + let instance = request + .parameters + .instance + .as_deref() + .expect("validated instance"); + let (row, context) = selected_context(&view, &contexts, instance)?; + let native_selection = selection(&context, request)?; + let work = Arc::new(Work { + app: app.clone(), + page: pending.page_label.clone(), + identity: pending.identity.clone(), + instance: instance.into(), + request: request.clone(), + initial_package: context.package, + operation: Mutex::new(None), + result: Mutex::new(None), + preparing: AtomicBool::new(false), + run_delivered: AtomicBool::new(false), + executing: AtomicBool::new(false), + request_closed: AtomicBool::new(false), + terminal_delivered: AtomicBool::new(false), + execution_settled: AtomicBool::new(false), + }); + with_page_owner(&state, &pending.page_label, &pending.identity, || { + check_request(&app, &operation_id, &pending)?; + state.builds.1.insert(&operation_id, work)?; + Ok( + json!({"pluginId":context.plugin_id,"buildSystemId":context.build_system_id,"root":context.root, + "workspaceRoot":context.workspace_root,"modelSource":row["modelSource"],"selection":native_selection}), + ) + }) + } + } else { + let work = match state.builds.1.get(&operation_id, caller.label()) { + Ok(work) => work, + Err(_) if matches!(phase, Phase::Cancel) => { + // A refused/unprepared request has no native process owner. + // Retried host cleanup is idempotent; foreign IDs gain no effect. + if let Ok(pending) = state.builds.owned(&operation_id, caller.label()) { + let _ = with_page_owner(&state, &pending.page_label, &pending.identity, || { + pending.complete(Err(bridge_error( + "requestCancelled", + "the page build operation was cancelled", + ))); + Ok(()) + }); + } + return Ok(json!({})); + } + Err(error) => return Err(error), + }; + let operation = work.operation.lock().expect("page operation").clone(); + if matches!(phase, Phase::Cancel) { + if let Some(operation) = operation { + operation.cancel().await?; + emit_cancelled(&work, &operation); + } + if let Ok(pending) = state.builds.owned(&operation_id, caller.label()) { + let _ = with_page_owner(&state, &work.page, &work.identity, || { + pending.complete(Err(bridge_error( + "requestCancelled", + "the page build operation was cancelled", + ))); + Ok(()) + }); + } + return Ok(json!({})); + } + let pending = state.builds.owned(&operation_id, caller.label()); + if !matches!(phase, Phase::Execute) + || work.request.method == "build.refresh" + || work.request.method == "build.dependencies" + { + let pending = pending.as_ref().map_err(|_| forbidden())?; + with_page_owner(&state, &work.page, &work.identity, || { + check_request(&app, &operation_id, pending) + })?; + } else { + with_page_owner(&state, &work.page, &work.identity, || Ok(()))?; + } + match phase { + Phase::Prepare => { + if work.preparing.swap(true, Ordering::AcqRel) { + return Err("plugin.build.busy".into()); + } + let pending = pending?; + let (view, contexts) = + read_view(app.clone(), operation_id.clone(), pending.clone(), false).await?; + let (_, context) = selected_context(&view, &contexts, &work.instance)?; + if context.package != work.initial_package { + return Err("plugin.build.packageChanged".into()); + } + let selection = selection(&context, &work.request)?; + let (review, operation) = super::super::super::build::page::prepare( + app.clone(), + &context, + work.request.method(), + selection, + ) + .await?; + if operation.context().package != work.initial_package + || operation.context().generation != context.generation + { + operation.revoke(); + return Err("plugin.build.staleContext".into()); + } + *work.operation.lock().expect("page operation") = Some(operation.clone()); + let valid = with_page_owner(&state, &work.page, &work.identity, || { + check_request(&app, &operation_id, &pending) + }); + if let Err(error) = valid { + operation.revoke(); + return Err(error); + } + Ok(review) + } + Phase::Execute => { + let operation = operation.ok_or_else(|| invalid("prepare is required"))?; + if work.executing.swap(true, Ordering::AcqRel) { + return Err("plugin.build.busy".into()); + } + with_page_owner(&state, &work.page, &work.identity, || { + if let Ok(pending) = &pending { + check_request(&app, &operation_id, pending)?; + } + if matches!(work.request.method(), "build.runTask" | "build.runTests") { + emit_run(&work, &operation, "running", None)?; + work.run_delivered.store(true, Ordering::Release); + pending + .as_ref() + .map_err(|_| forbidden())? + .complete(Ok(json!({"run":operation.id()}))); + } + Ok(()) + })?; + let result = operation.execute(work.request.method()).await; + if matches!(work.request.method(), "build.runTask" | "build.runTests") { + match &result { + Ok(value) => emit_terminal( + &work, + &operation, + match value["kind"].as_str() { + Some("succeeded") => "succeeded", + Some("cancelled") => "cancelled", + _ => "failed", + }, + value["exitCode"].as_i64(), + ), + Err(_) if operation.finished() => emit_terminal( + &work, + &operation, + if operation.cancelled() { + "cancelled" + } else { + "failed" + }, + None, + ), + // Cleanup failure retains the owner. Stop retry publishes + // cancellation only after native proves the tree exited. + Err(_) => {} + } + } + work.execution_settled.store(true, Ordering::Release); + let result = result?; + *work.result.lock().expect("page workflow result") = Some(result.clone()); + Ok(result) + } + Phase::StaticRefresh => { + if work.request.method != "build.refresh" { + return Err(invalid("only refresh can reload a passive model")); + } + let pending = pending?; + let checkpoint = app.clone(); + let owned = work.clone(); + let id = operation_id.clone(); + let view = run_build_task(app.clone(), move |registry, source, _| { + with_page_owner( + &checkpoint.state::(), + &owned.page, + &owned.identity, + || { + check_request(&checkpoint, &id, &pending)?; + if source.package(&owned.identity.plugin_id).as_ref() + != Some(&owned.initial_package) + { + return Err("plugin.build.packageChanged".into()); + } + let (view, contexts) = checked_view(registry, source, &owned.identity)?; + let (row, context) = + selected_context(&view, &contexts, &owned.instance)?; + if !matches!( + row["modelSource"].as_str(), + Some("static" | "hostIntegration") + ) { + return Err("plugin.build.unavailable".into()); + } + super::super::super::build::page::refresh_passive( + registry, source, &context, + ) + }, + ) + }) + .await?; + *work.result.lock().expect("page workflow result") = Some(json!({"view":view})); + Ok(serde_json::to_value(view).map_err(|e| e.to_string())?) + } + Phase::Finish => { + let pending = pending?; + let result = work + .result + .lock() + .expect("page workflow result") + .clone() + .ok_or_else(|| invalid("native work has not completed"))?; + let final_app = app.clone(); + let owned = work.clone(); + let id = operation_id.clone(); + run_build_task(app.clone(), move |registry, source, _| { + with_page_owner( + &final_app.state::(), + &owned.page, + &owned.identity, + || { + check_request(&final_app, &id, &pending)?; + let (view, _) = checked_view(registry, source, &owned.identity)?; + if source.package(&owned.identity.plugin_id).as_ref() + != Some(&owned.initial_package) + { + return Err("plugin.build.packageChanged".into()); + } + let value = if owned.request.method == "build.refresh" { + json!({}) + } else { + if result["terminal"]["kind"] != "succeeded" { + return Err("plugin.build.unavailable".into()); + } + dependency_result(&result)? + }; + if owned.request.method == "build.refresh" { + if result["view"]["generation"].as_u64() != Some(view.generation) + || result["view"]["instances"].as_array().and_then(|rows| { + rows.iter().find(|row| { + instance_id(row).as_deref() == Some(&owned.instance) + }) + }) != view.instances.iter().find(|row| { + instance_id(row).as_deref() == Some(&owned.instance) + }) + { + return Err("plugin.build.staleContext".into()); + } + } else if let Some(operation) = + owned.operation.lock().expect("page operation").as_ref() + { + if !registry.context_is_current(operation.context()) { + return Err("plugin.build.staleContext".into()); + } + } + pending.complete(Ok(value)); + Ok(json!({})) + }, + ) + }) + .await + } + _ => Err(invalid("invalid workflow phase")), + } + } +} +fn emit_cancelled(work: &Work, operation: &PageOperation) { + if work.run_delivered.load(Ordering::Acquire) && work.execution_settled.load(Ordering::Acquire) + { + emit_terminal(work, operation, "cancelled", None); + } +} + +/// The web contract returns Core's complete decoded tree, including its +/// selected project path. Do not rebuild it from presentation-only fields. +pub(super) fn dependency_result(result: &Value) -> Result { + let tree = &result["dependencyTree"]; + if !tree["project"].is_string() + || !tree["dependencies"].is_array() + || !tree["truncated"].is_boolean() + { + return Err("plugin.build.invalidOutput".into()); + } + Ok(tree.clone()) +} +fn emit_terminal(work: &Work, operation: &PageOperation, status: &str, exit: Option) { + let state = work.app.state::(); + let result = with_page_owner(&state, &work.page, &work.identity, || { + if work.terminal_delivered.swap(true, Ordering::AcqRel) { + return Ok(()); + } + emit_run(work, operation, status, exit) + }); + if let Err(error) = result { + eprintln!("page run notification was discarded: {error}"); + } +} +fn emit_run( + work: &Work, + operation: &PageOperation, + state: &str, + exit: Option, +) -> Result<(), String> { + let mut payload = json!({"run":operation.id(),"instance":work.instance,"state":state}); + if let Some(code) = exit { + payload["exitCode"] = json!(code); + } + work.app + .get_webview_window(&work.page) + .ok_or_else(forbidden)? + .eval(format!( + "if(window.__lithePageGeneration === {}) window.__litheDeliverEvent?.('build.runChanged', {payload});", work.identity.generation + )) + .map_err(|e| e.to_string()) +} + +/// Suspension completion queues a final read after the registry barrier drops. +pub(crate) fn queue_instance_changes(app: AppHandle) { + tauri::async_runtime::spawn(async move { + let result = run_build_task(app, |_, _, _| Ok(())).await; + if let Err(error) = result { + eprintln!("page instances notification was discarded: {error}"); + } + }); +} + +/// Observe the same registry after native or page edits, model loads and sync. +/// Deduplicate page payloads, and hold page ownership through final delivery. +pub(crate) fn publish_instance_changes( + app: &AppHandle, + registry: &BuildRegistry, + source: &dyn BuildPackageSource, +) { + let state = app.state::(); + let owners = state.owners.lock().expect("page owners"); + let mut previous = state.builds.1 .1.lock().expect("page build notifications"); + for (label, owner) in owners.iter() { + if owner.workspace_generation + != super::super::workspace_generation(&state, &owner.host_label, &owner.workspace_id) + { + continue; + } + let identity = BridgeIdentity { + generation: owner.generation, + workspace_generation: owner.workspace_generation, + plugin_id: owner.plugin_id.clone(), + window_id: owner.window_id.clone(), + host_label: owner.host_label.clone(), + workspace_id: owner.workspace_id.clone(), + workspace_path: owner.workspace_path.clone(), + state_id: owner.state_id.clone(), + }; + let payload = instances_json(®istry.view(&owner.host_label), &owner.plugin_id); + if previous.get(label) == Some(&(identity.clone(), payload.clone())) { + continue; + } + if checked_view(registry, source, &identity).is_err() { + continue; + } + if let Some(page) = app.get_webview_window(label) { + match page.eval(format!("if(window.__lithePageGeneration === {}) window.__litheDeliverEvent?.('build.instancesChanged', {payload});", owner.generation)) { + Ok(()) => { previous.insert(label.clone(), (identity, payload)); }, + Err(error) => eprintln!("page instances notification was discarded: {error}"), + } + } + } +} diff --git a/windows/tauri/src-tauri/src/project_window_registry.rs b/windows/tauri/src-tauri/src/project_window_registry.rs index c678ce945e..829f2b5fe4 100644 --- a/windows/tauri/src-tauri/src/project_window_registry.rs +++ b/windows/tauri/src-tauri/src/project_window_registry.rs @@ -16,6 +16,8 @@ pub struct ProjectWindowOwner { #[derive(Default)] pub struct ProjectWindowRegistry { owners: HashMap, + registrations: HashMap<(String, String), u64>, + next_registration: u64, } impl ProjectWindowRegistry { @@ -61,14 +63,42 @@ impl ProjectWindowRegistry { if owner.label == label && owner.workspace_id.is_none() { owner.workspace_id = workspace_id.map(str::to_owned); } + if let Some(id) = &owner.workspace_id { + let key = (owner.label.clone(), id.clone()); + if !self.registrations.contains_key(&key) { + self.next_registration = self.next_registration.saturating_add(1); + self.registrations.insert(key, self.next_registration); + } + } owner.clone() } + /// Read-only binding for feature snapshots. The directory identity, + /// window and workspace must all match; release/reclaim changes the ID. + pub fn registration(&self, identity: &Handle, label: &str, workspace_id: &str) -> Option { + let owner = self.owners.get(identity)?; + if owner.label != label || owner.workspace_id.as_deref() != Some(workspace_id) { + return None; + } + self.registrations + .get(&(label.into(), workspace_id.into())) + .copied() + } + + fn retain_registrations(&mut self) { + self.registrations.retain(|(label, id), _| { + self.owners.values().any(|owner| { + owner.label == *label && owner.workspace_id.as_deref() == Some(id.as_str()) + }) + }); + } + pub fn release(&mut self, label: &str, workspace_id: Option<&str>) { self.owners.retain(|_, owner| { owner.label != label || workspace_id.is_some_and(|id| owner.workspace_id.as_deref() != Some(id)) }); + self.retain_registrations(); } pub fn release_pending(&mut self, label: &str) { @@ -78,6 +108,7 @@ impl ProjectWindowRegistry { pub fn retain_windows(&mut self, exists: impl Fn(&str) -> bool) { self.owners.retain(|_, owner| exists(&owner.label)); + self.retain_registrations(); } } @@ -140,6 +171,40 @@ mod tests { ); } + #[test] + fn passive_registration_requires_the_claimed_directory_window_and_workspace() { + let root = TestDirectory::new(); + let other = TestDirectory::new(); + let mut registry = ProjectWindowRegistry::default(); + registry.claim(root.identity(), "main", Some("workspace-a")); + let first = registry + .registration(&root.identity(), "main", "workspace-a") + .unwrap(); + assert!(registry + .registration(&other.identity(), "main", "workspace-a") + .is_none()); + assert!(registry + .registration(&root.identity(), "workspace-other", "workspace-a") + .is_none()); + assert!(registry + .registration(&root.identity(), "main", "workspace-other") + .is_none()); + registry.claim(root.identity(), "main", Some("workspace-a")); + assert_eq!( + registry.registration(&root.identity(), "main", "workspace-a"), + Some(first) + ); + registry.release("main", Some("workspace-a")); + assert!(registry + .registration(&root.identity(), "main", "workspace-a") + .is_none()); + registry.claim(root.identity(), "main", Some("workspace-a")); + assert_ne!( + registry.registration(&root.identity(), "main", "workspace-a"), + Some(first) + ); + } + #[test] fn release_only_removes_the_matching_window_and_workspace() { let a = TestDirectory::new(); diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index dfbe8fa82b..2956cac720 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -976,6 +976,21 @@ fn execute_prelaunch( #[tauri::command] pub async fn run_start_process(app: AppHandle, args: StartProcessArgs) -> Result<(), String> { + run_start_process_with_admission(app, args, |start| start()).await +} + +/// Lets a native caller hold its admission epoch through process publication. +/// Reservation, process tree, output and stop ownership remain in this module. +pub(crate) async fn run_start_process_with_admission( + app: AppHandle, + args: StartProcessArgs, + admit: F, +) -> Result<(), String> +where + F: FnOnce(Box Result<(), String> + Send>) -> Result<(), String> + + Send + + 'static, +{ if args.window_label.trim().is_empty() { return Err("A run process must be started from an active window.".into()); } @@ -1006,7 +1021,9 @@ pub async fn run_start_process(app: AppHandle, args: StartProcessArgs) -> Result // predecessor that is still alive. stop_sessions(vec![previous], RUN_STOP_TIMEOUT)?; } - start_reserved_process(app, args, reservation) + admit(Box::new(move || { + start_reserved_process(app, args, reservation) + })) }) .await .map_err(|error| format!("Run launch preparation failed: {error}"))? @@ -1916,7 +1933,7 @@ fn resolve_generic_runtime( .ok_or_else(|| format!("No executable was found for toolchain {toolchain}.")) } -fn resolve_command_executable( +pub(crate) fn resolve_command_executable( command: &str, runtime_executable_paths: &HashMap, ) -> Option { @@ -2153,7 +2170,7 @@ fn is_batch_file(executable: &str) -> bool { }) } -fn command_for_executable(executable: &str, arguments: &[String]) -> Command { +pub(crate) fn command_for_executable(executable: &str, arguments: &[String]) -> Command { if is_batch_file(executable) { return batch_command(executable, arguments); } diff --git a/windows/tauri/src/extensions/run/extension-process-owner.test.ts b/windows/tauri/src/extensions/run/extension-process-owner.test.ts index 6d5e182321..89838712a1 100644 --- a/windows/tauri/src/extensions/run/extension-process-owner.test.ts +++ b/windows/tauri/src/extensions/run/extension-process-owner.test.ts @@ -72,3 +72,36 @@ test("disabling one extension preserves another extension in the same workspace" } expect(stopped).toEqual(["one", "two"]); }); + +test("a rejected launch with unconfirmed cleanup remains owned across failed stop retries", async () => { + const owner = new ExtensionProcessOwner(); + let mayOwnProcess = true; + let stops = 0; + await expect( + owner.start( + "run", + "workspace", + "sample.plugin", + async () => { + throw new Error("native cleanup unconfirmed"); + }, + async () => { + stops++; + if (stops === 1) throw new Error("stop unconfirmed"); + mayOwnProcess = false; + }, + undefined, + () => mayOwnProcess, + ), + ).rejects.toThrow("native cleanup unconfirmed"); + try { + await expect(owner.stop(undefined, "sample.plugin")).rejects.toThrow("stop unconfirmed"); + expect(stops).toBe(1); + await owner.stop(undefined, "sample.plugin"); + expect(stops).toBe(2); + await owner.stop(undefined, "sample.plugin"); + expect(stops).toBe(2); + } finally { + await owner.stop(); + } +}, 2000); diff --git a/windows/tauri/src/extensions/run/extension-process-owner.ts b/windows/tauri/src/extensions/run/extension-process-owner.ts index 3e500feb01..69eee0efdd 100644 --- a/windows/tauri/src/extensions/run/extension-process-owner.ts +++ b/windows/tauri/src/extensions/run/extension-process-owner.ts @@ -2,7 +2,14 @@ export class ExtensionProcessOwner { private readonly processes = new Map< string, - { extensionId: string; workspaceId: string; ready: Promise; stop: () => Promise } + { + extensionId: string; + workspaceId: string; + ready: Promise; + stop: () => Promise; + cancel?: () => void; + retainOnLaunchFailure?: () => boolean; + } >(); async start( @@ -11,14 +18,25 @@ export class ExtensionProcessOwner { extensionId: string, launch: () => Promise, stop: () => Promise, + cancel?: () => void, + retainOnLaunchFailure?: () => boolean, ) { // Schedule launch after registration, including synchronous launch failures. const ready = Promise.resolve().then(launch); - this.processes.set(id, { extensionId, workspaceId, ready, stop }); + this.processes.set(id, { + extensionId, + workspaceId, + ready, + stop, + cancel, + retainOnLaunchFailure, + }); try { await ready; } catch (error) { - this.processes.delete(id); + // A rejected native start may already have published a process whose + // cleanup failed. Keep its owner until a later stop confirms termination. + if (!retainOnLaunchFailure?.()) this.processes.delete(id); throw error; } } @@ -35,10 +53,12 @@ export class ExtensionProcessOwner { ); await Promise.all( owned.map(async ([id, entry]) => { - // A launch failure owns no process; successful late starts must still stop. + entry.cancel?.(); + // Ordinary launch failures own nothing. A caller that could not confirm + // cleanup must still drain its native process on this or a later retry. const started = await entry.ready.then( () => true, - () => false, + () => entry.retainOnLaunchFailure?.() ?? false, ); if (started) await entry.stop(); this.processes.delete(id); diff --git a/windows/tauri/src/features/bootstrap/bootstrap-async.ts b/windows/tauri/src/features/bootstrap/bootstrap-async.ts index 419f96e297..75573edd77 100644 --- a/windows/tauri/src/features/bootstrap/bootstrap-async.ts +++ b/windows/tauri/src/features/bootstrap/bootstrap-async.ts @@ -52,6 +52,17 @@ const asyncBootstrapSteps = [ await rescanDeclarativePlugins(); const { refreshDeclarativeUI } = await import("@/features/plugins/declarative-plugin-ui"); await refreshDeclarativeUI(); + const { startBuildSystemsRuntime } = await import( + "@/features/build-systems/build-systems-service" + ); + // Registers the plugin-lifecycle listener (catalog and registry + // revocation on disable/replace/uninstall) and warms the catalog. + // Detection runs when a build tool window mounts for the active + // workspace; the sync watcher lives with that window and is released + // when its last tool window unmounts. The returned cleanup is also + // wired to pagehide inside the service, so a destroyed window never + // keeps the listener alive. + await startBuildSystemsRuntime(); }, }, ] as const; diff --git a/windows/tauri/src/features/build-systems/actions/build-system-tool-window-actions.ts b/windows/tauri/src/features/build-systems/actions/build-system-tool-window-actions.ts new file mode 100644 index 0000000000..f84fa5748b --- /dev/null +++ b/windows/tauri/src/features/build-systems/actions/build-system-tool-window-actions.ts @@ -0,0 +1,18 @@ +import { applyRightToolWindowIntent } from "@/features/layout/actions/right-tool-window-actions"; +import { buildSystemViewId } from "../build-systems-store"; +import type { BuildCatalogEntry } from "../build-systems-types"; + +/** + * Tool-window actions for declarative build systems. The shell (docking, + * visibility) reuses the shared right tool-window registry; entries appear + * per enabled plugin build system and stay available with an empty state + * while no project is detected. + */ + +export function toggleBuildSystemWindow(entry: BuildCatalogEntry): void { + applyRightToolWindowIntent(buildSystemViewId(entry.pluginId, entry.buildSystemId), "toggle"); +} + +export function closeBuildSystemWindow(entry: BuildCatalogEntry): void { + applyRightToolWindowIntent(buildSystemViewId(entry.pluginId, entry.buildSystemId), "close"); +} diff --git a/windows/tauri/src/features/build-systems/build-dependencies-native.test.ts b/windows/tauri/src/features/build-systems/build-dependencies-native.test.ts new file mode 100644 index 0000000000..3805cc0826 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-dependencies-native.test.ts @@ -0,0 +1,72 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import * as tauri from "@/platform/tauri-core"; +import { buildDependenciesNative } from "./build-dependencies-native"; +import { validDependencies } from "./build-dependencies-types"; +const cleanups: Array<() => void> = []; +afterEach(() => + cleanups + .splice(0) + .reverse() + .forEach((cleanup) => cleanup()), +); + +test("dependency IPC sends only native IDs and single-use operation confirmation", async () => { + const calls: unknown[] = []; + const invoke = spyOn(tauri, "invoke").mockImplementation( + async (command: string, args?: unknown): Promise => { + calls.push({ command, args }); + return null as T; + }, + ); + cleanups.push(() => invoke.mockRestore()); + await buildDependenciesNative.prepare("D:/fixture", "dev.fixture", "build", "nested", { + projectId: "native-project", + }); + await buildDependenciesNative.execute("native-operation"); + await buildDependenciesNative.cancel("native-operation"); + expect(calls).toEqual([ + { + command: "declarative_plugins_build_prepare_dependencies", + args: { + workspaceRoot: "D:/fixture", + pluginId: "dev.fixture", + buildSystemId: "build", + root: "nested", + selection: { projectId: "native-project" }, + }, + }, + { + command: "declarative_plugins_build_load_dependencies", + args: { operationId: "native-operation" }, + }, + { + command: "declarative_plugins_build_cancel_operation", + args: { operationId: "native-operation" }, + }, + ]); +}); + +test("Core trees retain arbitrary string properties and reject malformed or excessive trees", () => { + expect( + validDependencies([ + { + name: "artifact", + group: "group", + version: "1", + scope: "compile", + properties: { resolution: "omittedForConflict", selectedVersion: "2" }, + children: [], + }, + ]), + ).toBe(true); + expect(validDependencies([{ name: "artifact", children: "output text" }])).toBe(false); + expect( + validDependencies([{ name: "artifact", properties: { resolution: 1 }, children: [] }]), + ).toBe(false); + expect( + validDependencies(Array.from({ length: 10_001 }, () => ({ name: "artifact", children: [] }))), + ).toBe(false); + const cycle = { name: "cycle", children: [] as unknown[] }; + cycle.children.push(cycle); + expect(validDependencies([cycle])).toBe(false); +}); diff --git a/windows/tauri/src/features/build-systems/build-dependencies-native.ts b/windows/tauri/src/features/build-systems/build-dependencies-native.ts new file mode 100644 index 0000000000..d5220bc150 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-dependencies-native.ts @@ -0,0 +1,26 @@ +import { invoke } from "@/platform/tauri-core"; +import { buildRunNative, type BuildRunNative } from "./build-run-native"; +import type { BuildDependenciesResult, BuildDependenciesReview } from "./build-dependencies-types"; + +export interface BuildDependenciesNative extends Omit { + prepare( + workspaceRoot: string, + pluginId: string, + buildSystemId: string, + root: string, + selection: { projectId: string | null }, + ): Promise; + execute(operationId: string): Promise; +} +export const buildDependenciesNative: BuildDependenciesNative = { + ...buildRunNative, + prepare: (workspaceRoot, pluginId, buildSystemId, root, selection) => + invoke("declarative_plugins_build_prepare_dependencies", { + workspaceRoot, + pluginId, + buildSystemId, + root, + selection: { projectId: selection.projectId }, + }), + execute: (operationId) => invoke("declarative_plugins_build_load_dependencies", { operationId }), +}; diff --git a/windows/tauri/src/features/build-systems/build-dependencies-session.test.ts b/windows/tauri/src/features/build-systems/build-dependencies-session.test.ts new file mode 100644 index 0000000000..4f081999e3 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-dependencies-session.test.ts @@ -0,0 +1,386 @@ +import { afterEach, expect, mock, test } from "bun:test"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { useBuildSystemsStore } from "./build-systems-store"; +import type { BuildView } from "./build-systems-types"; +import type { BuildDependenciesNative } from "./build-dependencies-native"; +import type { BuildDependenciesResult, BuildDependenciesReview } from "./build-dependencies-types"; +import { BuildDependenciesSession } from "./build-dependencies-session"; +import type { BuildOutputEvent } from "./output/build-output-types"; +import { useBuildOutputStore } from "./output/build-output-store"; +import * as lspAdapter from "@/platform/lsp-core-adapter"; + +const sharedLanguageStart = mock( + async (_args: unknown, _signal?: AbortSignal): Promise => {}, +); +mock.module("@/platform/lsp-core-adapter", () => ({ + ...lspAdapter, + ensureBuildProviderLanguageSession: sharedLanguageStart, +})); +mock.module("@/platform/tauri-core", () => ({ invoke: mock(async () => undefined) })); + +const ROOT = "D:/fixture/dependencies"; +const PLUGIN = "dev.lithe.fixture.dependencies"; +const pending: Array<() => void> = []; +const releases: Array<() => void> = []; +const work: Promise[] = []; +function deferred(fallback: T) { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + pending.push(() => resolve(fallback)); + return { promise, resolve, reject }; +} +async function flush() { + for (let index = 0; index < 32; index++) await Promise.resolve(); +} +afterEach(async () => { + releases + .splice(0) + .reverse() + .forEach((release) => release()); + pending.splice(0).forEach((finish) => finish()); + await Promise.allSettled(work.splice(0)); + await flush(); + workspaceRuntimeRegistry.resetForTests(); + sharedLanguageStart.mockReset().mockImplementation(async () => {}); +}); +function fixture() { + workspaceRuntimeRegistry.activateWorkspace( + { id: "dependencies-tests", name: "Fixture", path: ROOT }, + "ready", + ); + const store = useBuildSystemsStore.getStore("dependencies-tests"); + store.getState().actions.reset(); + const view: BuildView = { + generation: 9, + workspaceRoot: ROOT, + issues: [], + instances: [ + { + pluginID: PLUGIN, + buildSystemID: "fixture", + root: "reactor", + displayName: "Fixture", + markers: [], + modelSource: "hostIntegration", + model: { projects: [{ id: "app", name: "App", path: "reactor/app" }] }, + options: [], + stale: false, + syncDigest: "sync", + optionFingerprint: "options", + }, + ], + }; + store.getState().actions.applyView(view, store.getState().actions.beginRequest()); + const output = useBuildOutputStore.getStore("dependencies-tests"); + output.getState().actions.reset(); + const review: BuildDependenciesReview = { + operationId: "dependencies-1", + pluginId: PLUGIN, + buildSystemId: "fixture", + root: "reactor", + workspaceRoot: ROOT, + generation: 9, + executesProjectCode: true, + mayUseNetwork: true, + kind: "dependencies", + projectId: "app", + title: "Fixture", + }; + const cancelled: BuildDependenciesResult = { + ...review, + terminal: { kind: "cancelled" }, + dependencies: null, + }; + const result = deferred(cancelled); + const order: string[] = []; + const sinks: Array<(event: BuildOutputEvent) => void> = []; + const unlisten = mock(() => {}); + const native: BuildDependenciesNative = { + windowLabel: () => "workspace-dependencies", + save: mock(async () => { + order.push("save"); + }), + sync: mock(async () => { + order.push("sync"); + const actions = store.getState().actions; + actions.applyView(view, actions.beginRequest()); + }), + definition: mock(async () => ({ + tasks: [], + programs: [{ kind: "buildSystem", buildSystemID: "fixture" }], + dependencies: { source: "hostIntegration" as const }, + })), + prepare: mock(async () => { + order.push("prepare"); + return review; + }), + execute: mock(async () => { + order.push("execute"); + const value = await result.promise; + sinks[sinks.length - 1]?.({ ...review, type: "terminal", terminal: value.terminal }); + return value; + }), + cancel: mock(async () => { + order.push("cancel"); + }), + listen: mock(async (_window, sink) => { + order.push("listen"); + sinks.push(sink); + return unlisten; + }), + }; + const session = new BuildDependenciesSession( + "dependencies-tests", + PLUGIN, + "fixture", + "reactor", + store, + native, + ); + const release = session.attach(); + releases.push(release); + const start = () => { + const operation = session.start("app"); + work.push(operation); + return operation; + }; + const success: BuildDependenciesResult = { + ...review, + terminal: { kind: "succeeded", exitCode: 0 }, + dependencies: [ + { + name: "library", + version: "2", + properties: { selectedVersion: "2" }, + children: [{ name: "transitive", children: [] }], + }, + ], + }; + return { + session, + store, + view, + output, + review, + native, + result, + sinks, + order, + unlisten, + release, + start, + success, + }; +} + +test("prepare follows save/sync and explicit approval; Core tree publishes after completion", async () => { + const f = fixture(); + expect(f.order).toEqual([]); + const operation = f.start(); + await flush(); + expect(f.order).toEqual(["save", "sync", "prepare"]); + expect(f.session.getSnapshot().review).toEqual(f.review); + expect(f.native.execute).not.toHaveBeenCalled(); + f.session.decide(true); + await flush(); + expect(f.order.indexOf("listen")).toBeLessThan(f.order.indexOf("execute")); + expect(f.session.getSnapshot().dependencies).toBeNull(); + f.result.resolve(f.success); + await operation; + expect(f.session.getSnapshot().dependencies).toEqual(f.success.dependencies); + expect(f.session.getSnapshot().projectId).toBe("app"); + expect(f.unlisten).toHaveBeenCalledTimes(1); +}); +test("declined approval cancels the native review without invoking dependency command", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(false); + await operation; + expect(f.native.cancel).toHaveBeenCalledWith(f.review.operationId); + expect(f.native.execute).not.toHaveBeenCalled(); + expect(f.session.getSnapshot().dependencies).toBeNull(); +}); +test("same native busy barrier covers dependency consent, and duplicate starts do not prepare twice", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + expect(f.store.getState().status).toBe("loading"); + await f.session.start("app"); + expect(f.native.prepare).toHaveBeenCalledTimes(1); + f.session.decide(false); + await operation; + expect(f.store.getState().status).toBe("ready"); +}); +test("dependency Stop retries shared language cleanup after its initialization promise has rejected", async () => { + const f = fixture(); + f.review.provider = { + token: 7, + context: { + window: "workspace-dependencies", + workspaceRoot: ROOT, + generation: 9, + pluginId: PLUGIN, + packageIdentity: "fixture:digest", + buildSystem: "fixture", + instanceRoot: "reactor", + instanceDirectory: `${ROOT}/reactor`, + }, + executesProjectCode: true, + mayUseNetwork: true, + launchPlan: { buildProvider: { languageServer: "fixture-language" } }, + program: { + pluginId: PLUGIN, + languageId: "fixture-language", + serverPath: "fixture.exe", + serverArgs: [], + environment: {}, + }, + }; + const shared = deferred(undefined); + const retry = deferred(undefined); + const retryCleanup = mock(async () => { + await retry.promise; + }); + sharedLanguageStart.mockImplementation(async () => { + await shared.promise; + }); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + expect(sharedLanguageStart).toHaveBeenCalledTimes(1); + const stopping = f.session.cancel().catch((error: unknown) => error); + shared.reject(Object.assign(new Error("plugin.build.cleanupFailed"), { retryCleanup })); + expect(String(await stopping)).toContain("plugin.build.cleanupFailed"); + await operation; + expect(f.session.getSnapshot().phase).toBe("cancelFailed"); + expect(f.native.execute).not.toHaveBeenCalled(); + let finished = false; + const stopped = f.session.cancel().then(() => { + finished = true; + }); + work.push(stopped); + await flush(); + expect(f.native.cancel).toHaveBeenCalledTimes(2); + expect(retryCleanup).toHaveBeenCalledTimes(1); + expect(finished).toBe(false); + retry.resolve(undefined); + await stopped; + expect(f.session.getSnapshot().phase).toBe("ended"); + expect(f.session.getSnapshot().dependencies).toBeNull(); +}); + +test("cleanup failure keeps Stop retryable and never publishes a tree", async () => { + const f = fixture(); + f.native.execute = mock(async () => { + throw new Error("plugin.build.cleanupFailed"); + }); + const operation = f.start(); + await flush(); + f.session.decide(true); + await operation; + expect(f.session.getSnapshot().phase).toBe("cancelFailed"); + expect(f.session.getSnapshot().dependencies).toBeNull(); + expect(f.store.getState().status).toBe("loading"); + await f.session.start("app"); + expect(f.native.prepare).toHaveBeenCalledTimes(1); + await f.session.cancel(); + expect(f.session.getSnapshot().phase).toBe("ended"); +}); +for (const change of ["package", "configuration", "workspace", "window"] as const) { + test(`${change} invalidation rejects a late tree without waiting for a closed listener`, async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + const drain = mock(() => { + throw new Error("cancelled listeners cannot drain"); + }); + f.native.scheduleOutputDrain = drain; + if (change === "package") f.store.getState().actions.dropPlugin(PLUGIN); + else if (change === "configuration") { + const next = structuredClone(f.view); + next.instances[0]!.optionFingerprint = "changed"; + f.store.getState().actions.applyView(next, f.store.getState().actions.beginRequest()); + } else if (change === "workspace") + workspaceRuntimeRegistry.activateWorkspace( + { id: "other", path: "D:/other", name: "Other" }, + "ready", + ); + else f.release(); + f.result.resolve(f.success); + await operation; + expect(f.session.getSnapshot().dependencies).toBeNull(); + expect(drain).not.toHaveBeenCalled(); + f.release(); + workspaceRuntimeRegistry.resetForTests(); + }); +} +test("late review after disposal is cancelled even when acquisition could not be aborted", async () => { + const f = fixture(); + const review = deferred(f.review); + f.native.prepare = mock(async () => review.promise); + const operation = f.start(); + await flush(); + f.release(); + review.resolve(f.review); + await operation; + expect(f.native.cancel).toHaveBeenCalledWith(f.review.operationId); + expect(f.native.execute).not.toHaveBeenCalled(); +}); +test("published trees become stale after model generation change", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + f.result.resolve(f.success); + await operation; + expect(f.session.getSnapshot().stale).toBe(false); + const next = structuredClone(f.view); + next.generation++; + f.store.getState().actions.applyView(next, f.store.getState().actions.beginRequest()); + expect(f.session.getSnapshot().stale).toBe(true); +}); +test("wrong project result and events for another operation cannot publish", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + f.sinks[f.sinks.length - 1]!({ + ...f.review, + operationId: "older", + type: "chunk", + stream: "stdout", + text: "stale data", + }); + f.result.resolve({ ...f.success, projectId: "different" }); + await operation; + expect(f.session.getSnapshot().dependencies).toBeNull(); + expect(f.session.getSnapshot().phase).toBe("failed"); + expect(f.output.getState().sessions[f.session.run.sessionId]?.lines).toEqual([]); + await f.session.cancel(); +}); +test("invalid review retains the native owner if cancelling that review fails", async () => { + const f = fixture(); + f.native.prepare = mock(async () => ({ ...f.review, projectId: "wrong-project" })); + f.native.cancel = mock(async () => { + throw new Error("plugin.build.cleanupFailed"); + }); + await f.start(); + expect(f.native.execute).not.toHaveBeenCalled(); + expect(f.session.getSnapshot().phase).toBe("cancelFailed"); + expect(f.store.getState().status).toBe("loading"); + await f.session.start("app"); + expect(f.native.prepare).toHaveBeenCalledTimes(1); + f.native.cancel = mock(async () => {}); + await f.session.cancel(); + expect(f.session.getSnapshot().phase).toBe("ended"); +}); diff --git a/windows/tauri/src/features/build-systems/build-dependencies-session.ts b/windows/tauri/src/features/build-systems/build-dependencies-session.ts new file mode 100644 index 0000000000..1feda45901 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-dependencies-session.ts @@ -0,0 +1,291 @@ +import type { StoreApi } from "zustand"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { + BUILD_SYSTEMS_STORE_KEY, + buildSystemViewId, + useBuildSystemsStore, + type BuildSystemsState, +} from "./build-systems-store"; +import { BuildRunSession, matchesBuildRun, type BuildRunState } from "./build-run-session"; +import { buildDependenciesNative, type BuildDependenciesNative } from "./build-dependencies-native"; +import { + validDependencies, + type BuildDependencyNode, + type BuildDependenciesResult, + type BuildDependenciesReview, +} from "./build-dependencies-types"; +import { MAX_OUTPUT_SESSIONS } from "./output/build-output-store"; +import { validBuildProviderReview } from "./build-systems-types"; +import { + beginBuildProviderLanguage, + type BuildProviderLanguageInitialization, +} from "./build-provider-language"; + +export interface BuildDependenciesState extends BuildRunState { + dependencies: BuildDependencyNode[] | null; + projectId: string | null; + stale: boolean; + truncated: boolean; +} +interface Snapshot { + workspaceRoot: string | null; + generation: number | null; + options: string | null | undefined; + sync: string | null | undefined; + stale: boolean | undefined; + revocation: number; +} +/** Reuses the task/model owner's save, sync, approval, cancellation and + * cleanup barriers. This wrapper only retains the Core tree for display. */ +export class BuildDependenciesSession { + readonly run: BuildRunSession; + #state: BuildDependenciesState; + #listeners = new Set<() => void>(); + #epoch = 0; + #approved: Snapshot | null = null; + #published: Snapshot | null = null; + #candidate: BuildDependenciesResult | null = null; + #review: BuildDependenciesReview | null = null; + #validationError: string | null = null; + #attached = 0; + #sharedController: AbortController | null = null; + #sharedInitialization: BuildProviderLanguageInitialization | null = null; + constructor( + readonly workspaceId: string, + readonly pluginId: string, + readonly buildSystemId: string, + readonly root: string, + private store: StoreApi, + readonly native: BuildDependenciesNative = buildDependenciesNative, + ) { + this.run = new BuildRunSession( + workspaceId, + pluginId, + buildSystemId, + root, + store, + { + ...native, + prepare: async (workspaceRoot, plugin, system, instance, selection) => { + const review = await native.prepare(workspaceRoot, plugin, system, instance, { + projectId: selection.projectId, + }); + this.#review = review; + this.#approved = this.#snapshot(); + return review; + }, + validateReview: (review, selection) => { + native.validateReview?.(review, selection); + if ( + !validBuildProviderReview(review) || + review.kind !== "dependencies" || + (review as BuildDependenciesReview).projectId !== selection.projectId + ) + throw new Error("plugin.build.staleContext"); + }, + execute: async (operationId) => { + const epoch = this.#epoch; + if (!this.#review) throw new Error("plugin.build.staleContext"); + this.#sharedController = new AbortController(); + this.#sharedInitialization = beginBuildProviderLanguage( + this.#review, + this.#sharedController.signal, + ); + await this.#sharedInitialization.ready; + if (epoch !== this.#epoch || !this.#approved || !this.#current(this.#approved)) + throw new Error("plugin.build.cancelled"); + const result = await native.execute(operationId); + const review = this.#review; + if ( + !review || + !result || + !matchesBuildRun(review, { ...result, type: "terminal" }) || + result.projectId !== review.projectId || + result.kind !== "dependencies" || + (result.truncated != null && typeof result.truncated !== "boolean") || + (result.terminal.kind === "succeeded" + ? !validDependencies(result.dependencies) + : result.dependencies !== null) + ) { + this.#validationError = "plugin.build.invalidOutput"; + throw new Error("plugin.build.invalidOutput"); + } + this.#candidate = result; + return result.terminal; + }, + cancel: async (operationId) => { + this.#epoch += 1; + this.#candidate = null; + this.#sharedController?.abort(); + await native.cancel(operationId); + await this.#sharedInitialization?.cleanup(); + }, + }, + `build-dependencies-output:${JSON.stringify([pluginId, buildSystemId, root])}`, + ); + this.#state = { + ...this.run.getSnapshot(), + dependencies: null, + projectId: null, + stale: false, + truncated: false, + }; + } + getSnapshot = () => this.#state; + subscribe = (listener: () => void) => { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + }; + get canEvict() { + return this.run.canEvict; + } + #emit(patch: Partial) { + this.#state = { ...this.#state, ...this.run.getSnapshot(), ...patch }; + for (const listener of this.#listeners) listener(); + } + #snapshot(): Snapshot { + const state = this.store.getState(); + const instance = state.windows[ + buildSystemViewId(this.pluginId, this.buildSystemId) + ]?.instances.find((row) => row.root === this.root); + return { + workspaceRoot: state.workspaceRoot, + generation: state.generation, + options: instance?.optionFingerprint, + sync: instance?.syncDigest, + stale: instance?.stale, + revocation: state.pluginRevocationEpochs[this.pluginId] ?? 0, + }; + } + #current(snapshot: Snapshot) { + const now = this.#snapshot(); + const runtime = workspaceRuntimeRegistry.getWorkspace(this.workspaceId); + const state = this.store.getState(); + return ( + workspaceRuntimeRegistry.getActiveWorkspaceId() === this.workspaceId && + runtime?.status === "ready" && + runtime.stores.get(BUILD_SYSTEMS_STORE_KEY) === this.store && + runtime.descriptor.path === snapshot.workspaceRoot && + !state.revokedPlugins[this.pluginId] && + state.windows[buildSystemViewId(this.pluginId, this.buildSystemId)]?.selectedRoot === + this.root && + (Object.keys(snapshot) as Array).every((key) => snapshot[key] === now[key]) + ); + } + attach() { + this.#attached += 1; + const releaseRun = this.run.attach(); + const releaseRunState = this.run.subscribe(() => this.#emit({})); + const check = () => { + if (this.#published && !this.#current(this.#published)) this.#emit({ stale: true }); + }; + const releaseStore = this.store.subscribe(check); + const releaseRuntime = workspaceRuntimeRegistry.subscribe(check); + check(); + let released = false; + return () => { + if (released) return; + released = true; + --this.#attached; + this.#epoch += 1; + releaseStore(); + releaseRuntime(); + releaseRunState(); + releaseRun(); + }; + } + decide(accepted: boolean) { + this.run.decide(accepted); + } + cancel() { + this.#epoch += 1; + return this.run.cancel(); + } + async start(projectId: string | null) { + if ( + ["preparing", "review", "starting", "running", "stopping", "cancelFailed"].includes( + this.run.getSnapshot().phase, + ) + ) + return; + const epoch = ++this.#epoch; + this.#candidate = null; + this.#approved = null; + this.#published = null; + this.#review = null; + this.#sharedInitialization = null; + this.#sharedController = null; + this.#validationError = null; + this.#emit({ dependencies: null, projectId, stale: false, truncated: false }); + await this.run.start({ + kind: "dependencies", + projectId, + taskId: null, + targetId: null, + modelTaskId: null, + suite: null, + case: null, + userArguments: [], + }); + // A terminal event may already prove cleanup before an invalid invoke + // payload arrives. Preserve that proof, but report a failed tree read. + if ( + epoch === this.#epoch && + this.#validationError && + this.run.getSnapshot().phase === "ended" + ) { + this.#emit({ phase: "failed", error: this.#validationError }); + return; + } + const result = this.#candidate as BuildDependenciesResult | null; + const approved = this.#approved as Snapshot | null; + if ( + epoch === this.#epoch && + this.#attached > 0 && + result?.terminal.kind === "succeeded" && + approved && + this.#current(approved) && + this.run.getSnapshot().phase === "ended" + ) { + this.#published = approved; + this.#emit({ + dependencies: result.dependencies, + projectId, + stale: false, + truncated: result.truncated === true, + }); + } + } +} +const sessions = new WeakMap, Map>(); +export function getBuildDependenciesSession( + workspaceId: string, + pluginId: string, + buildSystemId: string, + root: string, + native: BuildDependenciesNative = buildDependenciesNative, +) { + const store = useBuildSystemsStore.getStore(workspaceId); + const rows = sessions.get(store) ?? new Map(); + sessions.set(store, rows); + const key = JSON.stringify([pluginId, buildSystemId, root]); + const existing = rows.get(key); + if (existing) return existing; + if (rows.size >= MAX_OUTPUT_SESSIONS) { + const free = [...rows].find(([, row]) => row.canEvict); + if (!free) throw new Error("plugin.build.busy"); + rows.delete(free[0]); + } + const session = new BuildDependenciesSession( + workspaceId, + pluginId, + buildSystemId, + root, + store, + native, + ); + rows.set(key, session); + return session; +} diff --git a/windows/tauri/src/features/build-systems/build-dependencies-types.ts b/windows/tauri/src/features/build-systems/build-dependencies-types.ts new file mode 100644 index 0000000000..7ec3e4dce8 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-dependencies-types.ts @@ -0,0 +1,61 @@ +import type { BuildModelReview } from "./build-systems-types"; +import type { BuildRunDefinition } from "./build-run-types"; +import type { BuildRunTerminal } from "./output/build-output-types"; + +/** Core's dependency node. Properties remain upstream data, never parsed here. */ +export interface BuildDependencyNode { + name: string; + group?: string | null; + version?: string | null; + scope?: string | null; + properties?: Record; + children: BuildDependencyNode[]; +} +export interface BuildDependenciesReview extends BuildModelReview { + projectId: string | null; + kind: "dependencies"; + title: string; +} +export interface BuildDependenciesResult extends BuildDependenciesReview { + terminal: BuildRunTerminal; + dependencies: BuildDependencyNode[] | null; + truncated?: boolean; + dependencyTree?: { + project: string; + dependencies: BuildDependencyNode[]; + truncated: boolean; + } | null; +} +export type BuildDependenciesDefinition = BuildRunDefinition; + +/** Validate the bounded Core shape before making it renderable. No ecosystem + * interpretation or output parsing takes place at this boundary. */ +export function validDependencies(value: unknown): value is BuildDependencyNode[] { + if (!Array.isArray(value)) return false; + const pending: Array<{ rows: unknown[]; depth: number }> = [{ rows: value, depth: 0 }]; + let count = 0; + while (pending.length) { + const { rows, depth } = pending.pop()!; + if (depth >= 64 && rows.length) return false; + for (const row of rows) { + if (++count > 10_000 || !row || typeof row !== "object") return false; + const node = row as BuildDependencyNode; + if (typeof node.name !== "string" || !Array.isArray(node.children)) return false; + if ( + [node.group, node.version, node.scope].some( + (field) => field != null && typeof field !== "string", + ) + ) + return false; + if ( + node.properties != null && + (typeof node.properties !== "object" || + Array.isArray(node.properties) || + Object.values(node.properties).some((field) => typeof field !== "string")) + ) + return false; + pending.push({ rows: node.children, depth: depth + 1 }); + } + } + return true; +} diff --git a/windows/tauri/src/features/build-systems/build-language-import.test.ts b/windows/tauri/src/features/build-systems/build-language-import.test.ts new file mode 100644 index 0000000000..4db384321d --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-language-import.test.ts @@ -0,0 +1,266 @@ +import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; +import * as javaConfiguration from "@/features/maven/services/java-maven-configuration"; +import * as lspAdapter from "@/platform/lsp-core-adapter"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { setPluginActive } from "@/features/plugins/declarative-plugin-lifecycle"; +import { useBuildSystemsStore } from "./build-systems-store"; +import { + prepareBuildLanguageImport, + bindJavaBuildLanguageImport, + javaBuildImportSource, + updateBuildLanguageImport, + type BuildLanguageImport, + type BuildLanguageImportDependencies, +} from "./build-language-import"; + +const scope = { workspaceId: "import-fixture", root: "C:/fixture" }; +const store = () => useBuildSystemsStore.getStore(scope.workspaceId); +const value: BuildLanguageImport = { + workspaceRoot: scope.root, + generation: 3, + identity: "native-snapshot", + mavenContext: { + version: 1, + reactorPath: "nested", + profiles: ["dev"], + settingsPath: "C:/fixture/settings.xml", + }, + javaGradle: null, +}; +function ready() { + store().setState({ status: "ready", generation: 3, workspaceRoot: scope.root }); +} +beforeEach(() => { + workspaceRuntimeRegistry.resetForTests(); + workspaceRuntimeRegistry.activateWorkspace( + { id: scope.workspaceId, name: "Import", path: scope.root }, + "ready", + ); + ready(); +}); +afterEach(() => workspaceRuntimeRegistry.resetForTests()); + +function ports(read: BuildLanguageImportDependencies["read"] = async () => value) { + return { + refresh: mock(async () => []), + read: mock(read), + } satisfies BuildLanguageImportDependencies; +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { resolve, promise }; +} + +test("passes the exact workspace, source and generation; validation rechecks native identity", async () => { + const bridge = ports(); + const prepared = await prepareBuildLanguageImport( + scope, + "C:/fixture/nested/src/App.java", + bridge, + ); + try { + expect(prepared.value).toEqual(value); + await prepared.validate(); + expect(bridge.read.mock.calls.map(([request]) => request)).toEqual([ + { + workspaceRoot: scope.root, + generation: 3, + sourcePath: "nested/src/App.java", + expectedIdentity: null, + }, + { + workspaceRoot: scope.root, + generation: 3, + sourcePath: "nested/src/App.java", + expectedIdentity: value.identity, + }, + ]); + expect(bridge.refresh).not.toHaveBeenCalled(); + } finally { + prepared.dispose(); + } +}, 2000); + +test("initial discovery is passive and a missing or failed native model never becomes legacy fallback", async () => { + store().setState({ status: "idle", generation: null }); + const bridge = ports(); + bridge.refresh.mockImplementation(async () => { + ready(); + return []; + }); + const prepared = await prepareBuildLanguageImport(scope, undefined, bridge); + prepared.dispose(); + expect(bridge.refresh).toHaveBeenCalledTimes(1); + for (const failure of [ + "plugin.build.invalidPlan", + "plugin.build.invalidSelection", + "plugin.build.modelReloadRequired", + ]) { + await expect( + prepareBuildLanguageImport( + scope, + undefined, + ports(async () => { + throw new Error(failure); + }), + ), + ).rejects.toThrow(failure); + } +}, 2000); + +test.each(["option", "model", "close", "switch-back"])( + "discards a deferred import after %s", + async (change) => { + const answer = deferred(); + const entered = deferred(); + const bridge = ports(async () => { + entered.resolve(); + return answer.promise; + }); + const task = prepareBuildLanguageImport(scope, "C:/fixture/App.java", bridge); + // Observe rejection without making Bun drain this still-controlled promise + // before the test has released its native response gate. + const outcome = task.catch((error: unknown) => error); + try { + await entered.promise; + if (change === "option") store().setState({ requestSequence: 1 }); + if (change === "model") store().setState({ generation: 4 }); + if (change === "close") { + workspaceRuntimeRegistry.activateWorkspace({ id: "welcome-import", name: "Welcome" }); + workspaceRuntimeRegistry.removeWorkspace(scope.workspaceId); + } + if (change === "switch-back") { + workspaceRuntimeRegistry.activateWorkspace({ + id: "other-import", + name: "Other", + path: "C:/other", + }); + workspaceRuntimeRegistry.activateWorkspace( + { id: scope.workspaceId, name: "Import", path: scope.root }, + "ready", + ); + } + answer.resolve(value); + const error = await outcome; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("plugin.build.staleContext"); + } finally { + answer.resolve(value); + await task.catch(() => undefined); + } + }, + 2000, +); + +test("same-version package replacement cannot reuse a previously prepared identity", async () => { + let reads = 0; + const bridge = ports(async () => ({ + ...value, + identity: ++reads === 1 ? "old-package-bytes" : "replacement-bytes", + })); + const prepared = await prepareBuildLanguageImport(scope, undefined, bridge); + try { + await expect(prepared.validate()).rejects.toThrow("plugin.build.staleContext"); + } finally { + prepared.dispose(); + } +}, 2000); + +test("plugin revocation invalidates the prepared import before another native read", async () => { + const id = "io.example.build-import"; + setPluginActive(id, true); + store().setState({ + windows: { + fixture: { + pluginId: id, + buildSystemId: "arbitrary", + displayName: "Fixture", + selectedRoot: ".", + instances: [], + }, + }, + }); + const bridge = ports(); + const prepared = await prepareBuildLanguageImport(scope, undefined, bridge); + try { + setPluginActive(id, false); + setPluginActive(id, true); + await expect(prepared.validate()).rejects.toThrow("superseded"); + expect(bridge.read).toHaveBeenCalledTimes(1); + } finally { + prepared.dispose(); + setPluginActive(id, false); + } +}, 2000); + +test("representative sources carry only scope identity, never an old configuration", () => { + bindJavaBuildLanguageImport(scope, "C:/fixture/nested/App.java"); + expect(javaBuildImportSource(scope)).toBe("C:/fixture/nested/App.java"); + expect(javaBuildImportSource({ ...scope, root: "C:/replacement" })).toBeUndefined(); +}); + +test("standalone Java retains its legacy import without inventing a build workspace", async () => { + workspaceRuntimeRegistry.activateWorkspace({ id: "loose-file", name: "Loose file" }, "empty"); + const bridge = ports(); + const prepared = await prepareBuildLanguageImport( + { workspaceId: "loose-file", root: "C:/loose" }, + "C:/loose/App.java", + bridge, + ); + try { + expect(prepared.value.mavenContext).toBeNull(); + expect(prepared.value.javaGradle).toBeNull(); + await prepared.validate(); + expect(bridge.read).not.toHaveBeenCalled(); + expect(bridge.refresh).not.toHaveBeenCalled(); + workspaceRuntimeRegistry.activateWorkspace( + { id: "project", name: "Project", path: "C:/project" }, + "ready", + ); + await expect(prepared.validate()).rejects.toThrow("staleContext"); + } finally { + prepared.dispose(); + } +}, 2000); + +test("option edit wiring updates a Maven-bound Java session but leaves Gradle for the next start", async () => { + const update = spyOn(javaConfiguration, "updateJavaMavenConfiguration").mockResolvedValue({ + kind: "noSession", + }); + try { + bindJavaBuildLanguageImport(scope, "C:/fixture/App.java", false); + await updateBuildLanguageImport(scope); + expect(update).not.toHaveBeenCalled(); + bindJavaBuildLanguageImport(scope, "C:/fixture/nested/App.java", true); + await updateBuildLanguageImport(scope); + expect(update).toHaveBeenCalledWith(scope.root, null, false, scope); + } finally { + update.mockRestore(); + } +}, 2000); + +test("another editor cannot redirect a live JDT session's import to a sibling reactor", async () => { + bindJavaBuildLanguageImport(scope, "C:/fixture/north/App.java", true); + const session = spyOn(lspAdapter, "getLspWorkspaceSessionSnapshot").mockReturnValue({ + id: "owned-java-session", + workspacePath: scope.root, + languageId: "java", + phase: "ready", + operationId: "start", + featureState: { phase: "unknown" }, + }); + const bridge = ports(); + let prepared: Awaited> | undefined; + try { + prepared = await prepareBuildLanguageImport(scope, "C:/fixture/south/App.java", bridge); + expect(bridge.read.mock.calls[0][0].sourcePath).toBe("north/App.java"); + bindJavaBuildLanguageImport(scope, "C:/fixture/south/App.java", true); + expect(javaBuildImportSource(scope)).toBe("C:/fixture/north/App.java"); + } finally { + prepared?.dispose(); + session.mockRestore(); + } +}, 2000); diff --git a/windows/tauri/src/features/build-systems/build-language-import.ts b/windows/tauri/src/features/build-systems/build-language-import.ts new file mode 100644 index 0000000000..a65ec48195 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-language-import.ts @@ -0,0 +1,213 @@ +import { invoke } from "@/platform/tauri-core"; +import { getLspWorkspaceSessionSnapshot } from "@/platform/lsp-core-adapter"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import type { WorkspaceRuntime } from "@/features/workspace/types/workspace-runtime.types"; +import { + workspaceScopeMatchesRoot, + type WorkspaceLaunchScope, +} from "@/features/workspace/types/workspace-launch-scope"; +import { pluginPlanGuard } from "@/features/plugins/declarative-plugin-lifecycle"; +import type { MavenLaunchContext } from "@/features/maven/types/maven.types"; +import type { JavaLspGradle } from "@/features/editor/lsp/java-lsp-host-api"; +import { getRelativePath } from "@/utils/path-helpers"; +import { useBuildSystemsStore } from "./build-systems-store"; +import { enqueueRegistryOperation, refreshBuildSystems } from "./build-systems-service"; + +// Core's language import intentionally omits execution-only skipTests/Java home. +export type MavenLanguageImportContext = Pick< + MavenLaunchContext, + "version" | "reactorPath" | "profiles" +> & + Partial; + +export interface BuildLanguageImport { + workspaceRoot: string; + generation: number; + identity: string; + mavenContext: MavenLanguageImportContext | null; + javaGradle: JavaLspGradle | null; +} + +export interface PreparedBuildLanguageImport { + value: BuildLanguageImport; + assertCurrent: () => void; + validate: () => Promise; + dispose: () => void; +} + +interface ImportRequest { + workspaceRoot: string; + generation: number; + sourcePath: string | null; + expectedIdentity: string | null; +} + +export interface BuildLanguageImportDependencies { + refresh: typeof refreshBuildSystems; + settle?: () => Promise; + read: (request: ImportRequest) => Promise; +} +const dependencies: BuildLanguageImportDependencies = { + refresh: refreshBuildSystems, + settle: () => enqueueRegistryOperation(async () => {}), + read: (request) => + enqueueRegistryOperation(() => + invoke("declarative_plugins_build_language_import", { ...request }), + ), +}; + +// Only the representative source identity is retained, never options or an +// import result. Each hot update resolves a fresh native snapshot for it. +const representatives = new WeakMap< + WorkspaceRuntime, + { + scope: WorkspaceLaunchScope; + sourcePath: string; + hasMavenImport: boolean; + } +>(); + +export function bindJavaBuildLanguageImport( + scope: WorkspaceLaunchScope, + filePath: string, + hasMavenImport = false, +): void { + const runtime = workspaceRuntimeRegistry.getWorkspace(scope.workspaceId); + // A running JDT session owns one representative reactor. Opening another + // editor must not redirect subsequent hot updates to an unrelated reactor. + if (activeJavaBuildImportSource(scope)) return; + if (runtime) + representatives.set(runtime, { scope: { ...scope }, sourcePath: filePath, hasMavenImport }); +} + +export function javaBuildImportSource(scope: WorkspaceLaunchScope): string | undefined { + const runtime = workspaceRuntimeRegistry.getWorkspace(scope.workspaceId); + const bound = runtime && representatives.get(runtime); + return bound && workspaceScopeMatchesRoot(scope, bound.scope.root) ? bound.sourcePath : undefined; +} + +export function activeJavaBuildImportSource(scope: WorkspaceLaunchScope): string | undefined { + const session = getLspWorkspaceSessionSnapshot({ workspacePath: scope.root, languageId: "java" }); + return session && !["failed", "stopped", "stopping"].includes(session.phase) + ? javaBuildImportSource(scope) + : undefined; +} + +/** Passive preparation shared by editor attach, workspace prewarm and Maven + * updates. A consumer owns the subscriptions until its final native check. */ +export async function prepareBuildLanguageImport( + scope: WorkspaceLaunchScope, + filePath?: string, + ports: BuildLanguageImportDependencies = dependencies, +): Promise { + const runtime = workspaceRuntimeRegistry.getWorkspace(scope.workspaceId); + const store = useBuildSystemsStore.getStore(scope.workspaceId); + const standalone = !runtime?.descriptor.path; + let stale = false; + let disposed = false; + const workspaceCurrent = () => + Boolean( + runtime && + workspaceRuntimeRegistry.getWorkspace(scope.workspaceId) === runtime && + workspaceRuntimeRegistry.getActiveWorkspaceId() === scope.workspaceId && + (standalone + ? !runtime.descriptor.path + : workspaceScopeMatchesRoot(scope, runtime.descriptor.path)), + ); + const assertWorkspace = () => { + if (stale || disposed || !workspaceCurrent()) throw new Error("plugin.build.staleContext"); + }; + const releaseWorkspace = workspaceRuntimeRegistry.subscribe(() => { + if (!workspaceCurrent()) stale = true; + }); + let releaseStore = () => {}; + const dispose = () => { + if (disposed) return; + disposed = true; + releaseWorkspace(); + releaseStore(); + }; + try { + assertWorkspace(); + // A loose file has no build-workspace registration. Preserve the existing + // standalone Java/Maven path while still rejecting a project switch. + if (standalone) + return { + value: { + workspaceRoot: scope.root, + generation: 0, + identity: "standalone", + mavenContext: null, + javaGradle: null, + }, + dispose, + assertCurrent: assertWorkspace, + validate: async () => { + assertWorkspace(); + }, + }; + if (store.getState().status === "idle") await ports.refresh(); + else if (store.getState().status === "loading") await ports.settle?.(); + assertWorkspace(); + const snapshot = store.getState(); + if ( + snapshot.status !== "ready" || + snapshot.generation === null || + !workspaceScopeMatchesRoot(scope, snapshot.workspaceRoot) + ) { + throw new Error("plugin.build.staleContext"); + } + const assertPlugins = pluginPlanGuard(); + const pluginIds = Object.values(snapshot.windows).map((window) => window.pluginId); + const assertCurrent = () => { + assertWorkspace(); + const state = store.getState(); + if (state !== snapshot) throw new Error("plugin.build.staleContext"); + for (const id of pluginIds) assertPlugins(id); + }; + releaseStore = store.subscribe(() => { + if (store.getState() !== snapshot) stale = true; + }); + const request: ImportRequest = { + workspaceRoot: snapshot.workspaceRoot!, + generation: snapshot.generation, + sourcePath: activeJavaBuildImportSource(scope) ?? filePath ?? null, + expectedIdentity: null, + }; + if (request.sourcePath) request.sourcePath = getRelativePath(request.sourcePath, scope.root); + const value = await ports.read(request); + assertCurrent(); + if ( + value.workspaceRoot !== request.workspaceRoot || + value.generation !== request.generation || + !value.identity + ) { + throw new Error("plugin.build.staleContext"); + } + return { + value, + dispose, + assertCurrent, + validate: async () => { + assertCurrent(); + const checked = await ports.read({ ...request, expectedIdentity: value.identity }); + assertCurrent(); + if (checked.identity !== value.identity) throw new Error("plugin.build.staleContext"); + }, + }; + } catch (error) { + dispose(); + throw error; + } +} + +/** Called only after a successful native option edit. Gradle has no live + * update path; the next launch reads its new offline/home options. */ +export async function updateBuildLanguageImport(scope: WorkspaceLaunchScope): Promise { + const runtime = workspaceRuntimeRegistry.getWorkspace(scope.workspaceId); + if (!runtime || !representatives.get(runtime)?.hasMavenImport || !javaBuildImportSource(scope)) + return; + const { updateJavaMavenConfiguration } = + await import("@/features/maven/services/java-maven-configuration"); + await updateJavaMavenConfiguration(scope.root, null, false, scope); +} diff --git a/windows/tauri/src/features/build-systems/build-provider-language.ts b/windows/tauri/src/features/build-systems/build-provider-language.ts new file mode 100644 index 0000000000..18b9561062 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-provider-language.ts @@ -0,0 +1,82 @@ +import { validBuildProviderReview, type BuildModelReview } from "./build-systems-types"; + +export interface BuildProviderLanguageInitialization { + ready: Promise; + cleanup(): Promise; +} + +/** Each caller retains its own cleanup failure until a later Stop retries + * the adapter's original owner; awaiting a rejected start again is no proof + * that the language process was reclaimed. */ +export function beginBuildProviderLanguage( + review: BuildModelReview, + signal?: AbortSignal, +): BuildProviderLanguageInitialization { + const ready = ensureBuildProviderLanguage(review, signal); + let failure: (Error & { retryCleanup?: () => Promise }) | null = null; + let cleaned = false; + return { + ready, + async cleanup() { + if (cleaned) return; + if (failure?.retryCleanup) { + await failure.retryCleanup(); + failure = null; + cleaned = true; + return; + } + await ready.catch((error: unknown) => { + if (error instanceof Error && error.message === "plugin.build.cleanupFailed") { + failure = error; + throw error; + } + }); + cleaned = true; + }, + }; +} + +/** Called after explicit approval. The editor's existing per-language owner + * deduplicates starts and restarts dead sessions; native Provider confirm then + * binds that exact same-plugin session. This module owns no LSP transport. */ +export async function ensureBuildProviderLanguage( + review: BuildModelReview, + signal?: AbortSignal, + start: (args: Record, signal?: AbortSignal) => Promise = async ( + args, + signal, + ) => { + await ( + await import("@/platform/tauri-core") + ).invoke("declarative_plugins_build_authorize_provider_language", { + operationId: review.operationId, + }); + if (signal?.aborted) throw new Error("plugin.build.cancelled"); + return (await import("@/platform/lsp-core-adapter")).ensureBuildProviderLanguageSession( + args, + signal, + ); + }, +): Promise { + if (!validBuildProviderReview(review)) throw new Error("plugin.build.staleContext"); + const provider = review.provider; + const language = (provider?.launchPlan.buildProvider as { languageServer?: string } | undefined) + ?.languageServer; + if (!provider || !language) return; + if (signal?.aborted) throw new Error("plugin.build.cancelled"); + await start( + { + workspacePath: review.workspaceRoot, + providerId: review.pluginId, + pluginId: review.pluginId, + languageId: language, + serverPath: provider.program.serverPath, + serverArgs: provider.program.serverArgs, + initializationOptions: provider.program.initializationOptions ?? null, + workspaceConfiguration: provider.program.workspaceConfiguration ?? null, + environment: provider.program.environment, + }, + signal, + ); + if (signal?.aborted) throw new Error("plugin.build.cancelled"); +} diff --git a/windows/tauri/src/features/build-systems/build-provider-review.test.ts b/windows/tauri/src/features/build-systems/build-provider-review.test.ts new file mode 100644 index 0000000000..60a43c92e8 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-provider-review.test.ts @@ -0,0 +1,125 @@ +import { expect, test } from "bun:test"; +import { validBuildProviderReview, type BuildModelReview } from "./build-systems-types"; +import { ensureBuildProviderLanguage } from "./build-provider-language"; + +function review(): BuildModelReview { + return { + operationId: "native-operation", + pluginId: "fixture.plugin", + buildSystemId: "fixture-system", + workspaceRoot: "C:/fixture", + root: "module", + generation: 4, + executesProjectCode: true, + mayUseNetwork: true, + provider: { + token: 1, + context: { + window: "main", + workspaceRoot: "\\\\?\\C:\\fixture", + generation: 4, + pluginId: "fixture.plugin", + buildSystem: "fixture-system", + packageIdentity: "package:digest", + instanceRoot: "module", + instanceDirectory: "C:/fixture/module", + }, + executesProjectCode: true, + mayUseNetwork: true, + launchPlan: { launch: { kind: "packaged" } }, + program: { + pluginId: "fixture.plugin", + languageId: "fixture-system", + serverPath: "C:/plugin/provider.exe", + serverArgs: ["--stdio"], + environment: {}, + }, + }, + }; +} +test("native Provider review retains program, instance and conservative risk approval", () => { + const value = review(); + expect(validBuildProviderReview(value)).toBe(true); + expect(value.provider?.program.serverArgs).toEqual(["--stdio"]); + expect(validBuildProviderReview({ ...value, executesProjectCode: false })).toBe(false); + expect(validBuildProviderReview({ ...value, mayUseNetwork: false })).toBe(false); +}, 1000); +test("foreign window-independent identity fields cannot borrow a build approval", () => { + for (const field of [ + "pluginId", + "buildSystem", + "workspaceRoot", + "generation", + "instanceRoot", + "packageIdentity", + ] as const) { + const value = review(); + const context = value.provider!.context as unknown as Record; + context[field] = field === "generation" ? 999 : field === "packageIdentity" ? "" : "foreign"; + expect(validBuildProviderReview(value)).toBe(false); + } +}, 1000); +test("incomplete Provider program review is refused while command reviews remain compatible", () => { + for (const field of ["program", "context", "launchPlan", "token"]) { + const value = review(); + (value.provider as unknown as Record)[field] = null; + expect(validBuildProviderReview(value)).toBe(false); + } + const value = review(); + delete value.provider; + expect(validBuildProviderReview(value)).toBe(true); +}, 1000); + +test("native UNC canonical identity preserves the original workspace approval", () => { + const value = review(); + value.workspaceRoot = "//server/share/project"; + value.provider!.context.workspaceRoot = "\\\\?\\UNC\\server\\share\\project"; + expect(validBuildProviderReview(value)).toBe(true); +}, 1000); + +test("shared Provider uses the existing language owner with the exact approved program", async () => { + const value = review(); + value.provider!.launchPlan.buildProvider = { languageServer: "fixture-language" }; + const calls: Record[] = []; + await ensureBuildProviderLanguage(value, undefined, async (args) => { + calls.push(args); + }); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + providerId: value.pluginId, + pluginId: value.pluginId, + languageId: "fixture-language", + workspacePath: value.workspaceRoot, + serverPath: value.provider!.program.serverPath, + serverArgs: ["--stdio"], + }); +}, 1000); + +test("declined or already cancelled shared preparation starts no language process", async () => { + const value = review(); + value.provider!.launchPlan.buildProvider = { languageServer: "fixture-language" }; + const controller = new AbortController(); + controller.abort(); + let calls = 0; + await expect( + ensureBuildProviderLanguage(value, controller.signal, async () => { + calls++; + }), + ).rejects.toThrow("plugin.build.cancelled"); + expect(calls).toBe(0); + await ensureBuildProviderLanguage(review(), undefined, async () => { + calls++; + }); + expect(calls).toBe(0); // Dedicated Provider initialization stays native. +}, 1000); + +test("cancellation during shared initialization cannot dispatch the later build request", async () => { + const value = review(); + value.provider!.launchPlan.buildProvider = { languageServer: "fixture-language" }; + const controller = new AbortController(); + await expect( + ensureBuildProviderLanguage(value, controller.signal, async () => { + controller.abort(); + }), + ).rejects.toThrow("plugin.build.cancelled"); +}, 1000); diff --git a/windows/tauri/src/features/build-systems/build-run-native.test.ts b/windows/tauri/src/features/build-systems/build-run-native.test.ts new file mode 100644 index 0000000000..80c737a65e --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-run-native.test.ts @@ -0,0 +1,90 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import * as native from "@/platform/tauri-core"; +import * as events from "@tauri-apps/api/event"; +import { buildRunNative } from "./build-run-native"; +import type { BuildRunSelection } from "./build-run-types"; + +const cleanups: Array<() => void> = []; +afterEach(() => { + cleanups + .splice(0) + .reverse() + .forEach((cleanup) => cleanup()); +}); + +test("task/test IPC sends only native serde selection fields; the host supplies window and executable identity", async () => { + const calls: Array<{ command: string; args: unknown }> = []; + const invoke = spyOn(native, "invoke").mockImplementation( + async (command: string, args?: unknown): Promise => { + calls.push({ command, args }); + return (command.endsWith("execute_run") ? { kind: "succeeded", exitCode: 0 } : null) as T; + }, + ); + cleanups.push(() => invoke.mockRestore()); + const selection: BuildRunSelection = { + kind: "test", + taskId: null, + projectId: "project-id", + targetId: "target-id", + modelTaskId: null, + suite: "fixture suite", + case: "fixture case", + userArguments: ["literal value with spaces"], + }; + await buildRunNative.prepare( + "D:/fixture/workspace", + "dev.fixture.plugin", + "fixture-system", + "nested", + selection, + ); + await buildRunNative.execute("native-operation"); + await buildRunNative.cancel("native-operation"); + expect(calls).toEqual([ + { + command: "declarative_plugins_build_prepare_run", + args: { + workspaceRoot: "D:/fixture/workspace", + pluginId: "dev.fixture.plugin", + buildSystemId: "fixture-system", + root: "nested", + selection, + }, + }, + { command: "declarative_plugins_build_execute_run", args: { operationId: "native-operation" } }, + { + command: "declarative_plugins_build_cancel_operation", + args: { operationId: "native-operation" }, + }, + ]); +}); +test("output subscription uses the native window target and returns its actual unlistener", async () => { + let listener: ((event: { payload: unknown }) => void) | undefined; + const off = () => {}; + const listen = spyOn(events, "listen").mockImplementation(async (_event, callback) => { + listener = callback as typeof listener; + return off; + }); + cleanups.push(() => listen.mockRestore()); + const received: unknown[] = []; + const release = await buildRunNative.listen("workspace-window-A", (event) => + received.push(event), + ); + expect(listen).toHaveBeenCalledWith("declarative-build-output", expect.any(Function), { + target: { kind: "Window", label: "workspace-window-A" }, + }); + const payload = { + operationId: "native-operation", + pluginId: "dev.fixture.plugin", + buildSystemId: "fixture-system", + workspaceRoot: "D:/fixture/workspace", + root: ".", + generation: 7, + type: "chunk", + stream: "stdout", + text: "first", + }; + listener!({ payload }); + expect(received).toEqual([payload]); + expect(release).toBe(off); +}); diff --git a/windows/tauri/src/features/build-systems/build-run-native.ts b/windows/tauri/src/features/build-systems/build-run-native.ts new file mode 100644 index 0000000000..ea4b315477 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-run-native.ts @@ -0,0 +1,82 @@ +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { listen } from "@tauri-apps/api/event"; +import { invoke } from "@/platform/tauri-core"; +import { syncInstance } from "./build-systems-service"; +import type { BuildRunDefinition, BuildRunReview, BuildRunSelection } from "./build-run-types"; +import type { BuildOutputEvent, BuildRunTerminal } from "./output/build-output-types"; + +export interface BuildRunNative { + windowLabel(): string; + definition(pluginId: string, buildSystemId: string): Promise; + save(workspaceId: string): Promise; + sync(pluginId: string, buildSystemId: string, root: string): Promise; + prepare( + workspaceRoot: string, + pluginId: string, + buildSystemId: string, + root: string, + selection: BuildRunSelection, + ): Promise; + execute(operationId: string): Promise; + /** Additional result identity checks run after the common owner retains the review ID. */ + validateReview?: (review: BuildRunReview, selection: BuildRunSelection) => void; + cancel(operationId: string): Promise; + listen(windowLabel: string, sink: (event: BuildOutputEvent) => void): Promise<() => void>; + /** Injected only for deterministic event-drain timeout tests. */ + scheduleOutputDrain?: (callback: () => void, milliseconds: number) => () => void; +} + +export const buildRunNative: BuildRunNative = { + windowLabel: () => getCurrentWindow().label, + definition: async (pluginId, buildSystemId) => { + const { getDeclarativePluginManifest, listDeclarativePlugins } = + await import("@/features/plugins/declarative-plugin-launch"); + const { invokePluginDetailsService } = + await import("@/features/plugins/details/plugin-details.service"); + const [manifest, plugins] = await Promise.all([ + getDeclarativePluginManifest(pluginId), + listDeclarativePlugins(), + ]); + const plugin = plugins.find( + (row) => row.pluginId === pluginId && row.enabled && row.status === "accepted", + ); + const system = ( + manifest?.buildSystems as Array | undefined + )?.find((row) => row.id === buildSystemId); + if (!plugin || !system) throw new Error("plugin.build.unavailable"); + const details = await invokePluginDetailsService.load({ pluginId, source: plugin.source }); + const programs = details.programs.filter((program) => { + const value = program as { kind?: string; buildSystemID?: string } | null; + return value?.kind === "buildSystem" && value.buildSystemID === buildSystemId; + }); + if (!details.enabled || details.status !== "accepted" || !programs.length) + throw new Error("plugin.build.unavailable"); + return { + tasks: system.tasks ?? [], + test: system.test, + dependencies: system.dependencies, + programs, + }; + }, + save: async (workspaceId) => + ( + await import("@/features/editor/services/save-workspace-before-launch") + ).saveWorkspaceBeforeLaunch(workspaceId), + sync: syncInstance, + prepare: (workspaceRoot, pluginId, buildSystemId, root, selection) => + invoke("declarative_plugins_build_prepare_run", { + workspaceRoot, + pluginId, + buildSystemId, + root, + selection, + }), + execute: (operationId) => invoke("declarative_plugins_build_execute_run", { operationId }), + cancel: (operationId) => invoke("declarative_plugins_build_cancel_operation", { operationId }), + // Native's payload has no window field; the Tauri target is the window + // identity boundary. Every payload additionally carries all six run keys. + listen: (label, sink) => + listen("declarative-build-output", ({ payload }) => sink(payload), { + target: { kind: "Window", label }, + }), +}; diff --git a/windows/tauri/src/features/build-systems/build-run-session.test.ts b/windows/tauri/src/features/build-systems/build-run-session.test.ts new file mode 100644 index 0000000000..797dfe8418 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-run-session.test.ts @@ -0,0 +1,630 @@ +import { afterEach, expect, mock, test } from "bun:test"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { buildSystemViewId, useBuildSystemsStore } from "./build-systems-store"; +import type { BuildView } from "./build-systems-types"; +import type { BuildRunNative } from "./build-run-native"; +import type { BuildRunReview, BuildRunSelection } from "./build-run-types"; +import { BuildRunSession, OUTPUT_DRAIN_TIMEOUT_MS } from "./build-run-session"; +import { useBuildOutputStore } from "./output/build-output-store"; +import type { + BuildOperationIdentity, + BuildOutputEvent, + BuildRunTerminal, +} from "./output/build-output-types"; + +const ROOT = "D:/fixture/run-workspace"; +const PLUGIN = "dev.lithe.fixture.tasks"; +const VIEW = buildSystemViewId(PLUGIN, "fixture"); +const pending: Array<() => void> = []; +const releases: Array<() => void> = []; +const work: Promise[] = []; +function deferred(fallback: T) { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + pending.push(() => resolve(fallback)); + return { promise, resolve, reject }; +} +async function flush() { + for (let index = 0; index < 24; index++) await Promise.resolve(); +} +afterEach(async () => { + releases + .splice(0) + .reverse() + .forEach((release) => release()); + pending.splice(0).forEach((resolve) => resolve()); + await Promise.allSettled(work.splice(0)); + await flush(); + workspaceRuntimeRegistry.resetForTests(); +}); +function view(): BuildView { + return { + generation: 7, + workspaceRoot: ROOT, + issues: [], + instances: [ + { + pluginID: PLUGIN, + buildSystemID: "fixture", + root: ".", + displayName: "Fixture", + markers: [], + modelSource: "command", + model: { projects: [{ id: "project", name: "App", path: "." }] }, + options: [], + stale: false, + syncDigest: "sync", + optionFingerprint: "options", + }, + ], + }; +} +const selection: BuildRunSelection = { + kind: "task", + taskId: "build", + projectId: "project", + targetId: null, + modelTaskId: null, + suite: null, + case: null, + userArguments: ["--flag with spaces"], +}; +function fixture() { + workspaceRuntimeRegistry.activateWorkspace( + { id: "run-tests", name: "Fixture", path: ROOT }, + "ready", + ); + const store = useBuildSystemsStore.getStore("run-tests"); + store.getState().actions.reset(); + const initial = view(); + initial.instances.push({ ...initial.instances[0]!, root: "nested" }); + store.getState().actions.applyView(initial, store.getState().actions.beginRequest()); + const output = useBuildOutputStore.getStore("run-tests"); + output.getState().actions.reset(); + const review: BuildRunReview = { + operationId: "native-run-1", + pluginId: PLUGIN, + buildSystemId: "fixture", + workspaceRoot: ROOT, + root: ".", + generation: 7, + executesProjectCode: true, + mayUseNetwork: true, + kind: "task", + title: "Build fixture", + }; + const terminal = deferred({ kind: "cancelled" }); + const order: string[] = []; + const sinks: Array<(event: BuildOutputEvent) => void> = []; + const unlisten = mock(() => {}); + const native: BuildRunNative = { + windowLabel: () => "workspace-fixture", + definition: mock(async (): Promise => { + order.push("definition"); + return { + tasks: [{ id: "build", displayName: "Build", scopes: ["project"] }], + programs: [ + { + kind: "buildSystem", + buildSystemID: "fixture", + invocation: { executables: [{ kind: "command", command: "fixture-tool" }] }, + }, + ], + }; + }), + save: mock(async () => { + order.push("save"); + }), + sync: mock(async () => { + order.push("sync"); + const actions = store.getState().actions; + actions.applyView(initial, actions.beginRequest()); + }), + prepare: mock(async () => { + order.push("prepare"); + return review; + }), + execute: mock(async () => { + order.push("execute"); + const result = await terminal.promise; + sinks[sinks.length - 1]?.({ ...review, type: "terminal", terminal: result }); + return result; + }), + cancel: mock(async () => { + order.push("cancel"); + }), + listen: mock(async (_window, sink) => { + order.push("listen"); + sinks.push(sink); + return unlisten; + }), + }; + const session = new BuildRunSession("run-tests", PLUGIN, "fixture", ".", store, native); + const release = session.attach(); + releases.push(release); + const start = () => { + const result = session.start(selection); + work.push(result); + return result; + }; + const identity: BuildOperationIdentity = { ...review, title: review.title }; + const send = ( + event: Omit & Partial, + ) => sinks[sinks.length - 1]!({ ...identity, ...event } as BuildOutputEvent); + const state = () => output.getState().sessions[session.sessionId]; + return { + session, + store, + output, + native, + review, + terminal, + order, + sinks, + unlisten, + release, + start, + identity, + send, + state, + }; +} + +test("save, sync and native prepare are ordered; neither opening nor review executes a command", async () => { + const f = fixture(); + expect(f.order).toEqual([]); + const operation = f.start(); + await flush(); + expect(f.order).toEqual(["save", "sync", "definition", "prepare"]); + expect(f.session.getSnapshot().review).toEqual(f.review); + expect(f.native.prepare).toHaveBeenCalledWith(ROOT, PLUGIN, "fixture", ".", selection); + expect(f.native.execute).toHaveBeenCalledTimes(0); + f.session.decide(false); + await operation; + expect(f.native.cancel).toHaveBeenCalledWith(f.review.operationId); + expect(f.native.execute).toHaveBeenCalledTimes(0); +}); +test("the existing build busy barrier spans consent and execution so queued file sync cannot invalidate its own launch", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + expect(f.store.getState().status).toBe("loading"); + f.session.decide(true); + await flush(); + expect(f.store.getState().status).toBe("loading"); + f.terminal.resolve({ kind: "succeeded", exitCode: 0 }); + await operation; + expect(f.store.getState().status).toBe("ready"); +}); +test("listener acquisition completes before execute, including synchronous output and a fast terminal", async () => { + const f = fixture(); + const acquisition = deferred<() => void>(f.unlisten); + f.native.listen = mock(async (_label, sink) => { + f.sinks.push(sink); + f.order.push("listen"); + return acquisition.promise; + }); + f.native.execute = mock(async (): Promise => { + f.send({ type: "chunk", stream: "stdout", text: "first output\n" }); + f.send({ type: "chunk", stream: "stderr", text: "warning\n" }); + f.send({ type: "terminal", terminal: { kind: "succeeded", exitCode: 0 } }); + return { kind: "succeeded", exitCode: 0 }; + }); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + expect(f.native.execute).toHaveBeenCalledTimes(0); + acquisition.resolve(f.unlisten); + await operation; + expect(f.native.listen).toHaveBeenCalledWith("workspace-fixture", expect.any(Function)); + expect(f.state()!.lines.map((row) => [row.stream, row.text])).toEqual([ + ["stdout", "first output"], + ["stderr", "warning"], + ]); + expect(f.state()!.status).toBe("succeeded"); + expect(f.unlisten).toHaveBeenCalledTimes(1); +}); +test("cancel failure keeps the same native owner; retry waits for the native terminal result", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + f.native.cancel = mock(async () => { + throw new Error("fixture cleanup failure"); + }); + await expect(f.session.cancel()).rejects.toThrow("fixture cleanup failure"); + expect(f.state()!.status).toBe("running"); + expect(f.session.getSnapshot().phase).toBe("cancelFailed"); + expect(f.store.getState().status).toBe("loading"); + await f.session.start(selection); + expect(f.native.execute).toHaveBeenCalledTimes(1); + const confirmation = deferred(undefined); + f.native.cancel = mock(() => confirmation.promise); + const retry = f.session.cancel(); + await flush(); + expect(f.session.getSnapshot().phase).toBe("stopping"); + confirmation.resolve(); + await retry; + expect(f.state()!.status).toBe("running"); + f.terminal.resolve({ kind: "cancelled", exitCode: null }); + await operation; + expect(f.state()!.status).toBe("cancelled"); + expect(f.store.getState().status).toBe("ready"); + expect(f.native.cancel).toHaveBeenCalledWith(f.review.operationId); +}); +test("a fast invoke result does not truncate queued chunks or decoded diagnostics before the terminal event", async () => { + const f = fixture(); + let timeout: (() => void) | undefined; + const cancelTimer = mock(() => { + timeout = undefined; + }); + f.native.scheduleOutputDrain = (callback, milliseconds) => { + expect(milliseconds).toBe(OUTPUT_DRAIN_TIMEOUT_MS); + timeout = callback; + return cancelTimer; + }; + f.native.execute = mock( + async (): Promise => ({ kind: "succeeded", exitCode: 0 }), + ); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + expect(f.state()!.status).toBe("running"); + expect(timeout).toBeDefined(); + f.send({ type: "chunk", stream: "stdout", text: "queued after invoke\n" }); + f.send({ + type: "diagnostics", + diagnostics: [{ path: "app.ts", line: 2, severity: "warning", message: "queued diagnostic" }], + }); + f.send({ type: "terminal", terminal: { kind: "succeeded", exitCode: 0 } }); + await operation; + expect(f.state()!.lines[0]!.text).toBe("queued after invoke"); + expect(f.state()!.diagnostics[0]!.message).toBe("queued diagnostic"); + expect(cancelTimer).toHaveBeenCalledTimes(1); + expect(timeout).toBeUndefined(); +}); +test("missing terminal delivery uses the real invoke result after a bounded drain and reports missing output", async () => { + const f = fixture(); + let expire: (() => void) | undefined; + const cancelTimer = mock(() => { + expire = undefined; + }); + f.native.scheduleOutputDrain = (callback) => { + expire = callback; + return cancelTimer; + }; + f.native.execute = mock(async (): Promise => ({ kind: "failed", exitCode: 2 })); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + expect(f.state()!.status).toBe("running"); + expire!(); + await operation; + expect(f.state()!.status).toBe("failed"); + expect(f.state()!.terminal!.exitCode).toBe(2); + expect(f.state()!.subscriptionError).toContain("outputUnavailable"); + expect(cancelTimer).toHaveBeenCalledTimes(1); +}); +test("actual native completion releases a failed Stop owner and a late cancellation error cannot affect the next review", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + const cancellation = deferred(undefined); + f.native.cancel = mock(() => cancellation.promise); + const stop = f.session.cancel().catch((error) => error); + await flush(); + f.terminal.resolve({ kind: "succeeded", exitCode: 0 }); + await operation; + expect(f.state()!.status).toBe("succeeded"); + f.review.operationId = "native-run-2"; + const next = f.start(); + await flush(); + expect(f.session.getSnapshot().phase).toBe("review"); + cancellation.reject(new Error("old cancellation error")); + await stop; + expect(f.session.getSnapshot().phase).toBe("review"); + expect(f.session.getSnapshot().error).toBeNull(); + f.native.cancel = mock(async () => {}); + f.session.decide(false); + await next; +}); +test("late cancellation success cannot close a replacement operation's event subscription", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + const cancellation = deferred(undefined); + f.native.cancel = mock(() => cancellation.promise); + const stop = f.session.cancel(); + await flush(); + f.terminal.resolve({ kind: "succeeded", exitCode: 0 }); + await operation; + f.review.operationId = "native-run-2"; + const nextTerminal = deferred({ kind: "cancelled" }); + f.native.execute = mock(async () => { + const terminal = await nextTerminal.promise; + f.sinks[f.sinks.length - 1]!({ ...f.review, type: "terminal", terminal }); + return terminal; + }); + const next = f.start(); + await flush(); + f.session.decide(true); + await flush(); + const releasedBefore = f.unlisten.mock.calls.length; + cancellation.resolve(); + await stop; + expect(f.unlisten.mock.calls.length).toBe(releasedBefore); + expect(f.session.getSnapshot().phase).toBe("running"); + f.sinks[f.sinks.length - 1]!({ + ...f.review, + type: "chunk", + stream: "stdout", + text: "replacement output\n", + }); + expect(f.state()!.lines[0]!.text).toBe("replacement output"); + nextTerminal.resolve({ kind: "cancelled" }); + await next; +}); +test("execute rejection cannot invent a terminal or discard the retryable owner", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + f.terminal.reject(new Error("plugin.build.cleanupFailed")); + await operation; + expect(f.state()!.status).toBe("running"); + expect(f.session.getSnapshot().phase).toBe("cancelFailed"); + expect(f.session.getSnapshot().error).toContain("cleanupFailed"); + await f.session.start(selection); + expect(f.native.prepare).toHaveBeenCalledTimes(1); +}); +test("a late authoritative terminal releases the retained owner after an execute rejection", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + f.terminal.reject(new Error("plugin.build.cleanupFailed")); + await operation; + expect(f.session.getSnapshot().phase).toBe("cancelFailed"); + f.send({ type: "terminal", terminal: { kind: "cancelled" } }); + expect(f.state()!.status).toBe("cancelled"); + expect(f.session.getSnapshot().phase).toBe("ended"); + expect(f.unlisten).toHaveBeenCalledTimes(1); +}); +test("native cleanup confirmation can release an execute rejection without inventing an early terminal", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + f.terminal.reject(new Error("plugin.build.cleanupFailed")); + await operation; + const gate = deferred(undefined); + f.native.cancel = mock(() => gate.promise); + const stop = f.session.cancel(); + await flush(); + expect(f.state()!.status).toBe("running"); + gate.resolve(); + await stop; + expect(f.state()!.status).toBe("cancelled"); + expect(f.session.getSnapshot().phase).toBe("ended"); +}); +test("a review cancellation failure is retained without an automatic second cancellation", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.native.cancel = mock(async () => { + throw new Error("fixture review cleanup failed"); + }); + await expect(f.session.cancel()).rejects.toThrow("fixture review cleanup failed"); + await operation; + expect(f.native.cancel).toHaveBeenCalledTimes(1); + expect(f.session.getSnapshot().phase).toBe("cancelFailed"); + expect(f.native.execute).toHaveBeenCalledTimes(0); + f.native.cancel = mock(async () => {}); + await f.session.cancel(); + expect(f.session.getSnapshot().phase).toBe("ended"); +}); +test("panel reopening preserves a failed Stop handle and receives the same operation's remaining events", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + const staleSink = f.sinks[0]!; + f.native.cancel = mock(async () => { + throw new Error("fixture stop failed while closed"); + }); + f.release(); + await flush(); + expect(f.unlisten).toHaveBeenCalledTimes(1); + expect(f.session.getSnapshot().phase).toBe("cancelFailed"); + releases.push(f.session.attach()); + await flush(); + staleSink({ ...f.identity, type: "chunk", stream: "stdout", text: "closed callback" }); + f.send({ type: "chunk", stream: "stdout", text: "reopened\n" }); + expect(f.state()!.lines.map((row) => row.text)).toEqual(["reopened"]); + f.native.cancel = mock(async () => {}); + await f.session.cancel(); + f.terminal.resolve({ kind: "cancelled" }); + await operation; + expect(f.state()!.status).toBe("cancelled"); +}); +for (const key of [ + "operationId", + "pluginId", + "buildSystemId", + "workspaceRoot", + "root", + "generation", +] as const) { + test(`foreign ${key} events cannot enter the output store`, async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + const wrong = { + ...f.identity, + [key]: key === "generation" ? 8 : "foreign", + type: "chunk", + stream: "stdout", + text: "wrong", + }; + f.sinks[0]!(wrong as BuildOutputEvent); + expect(f.state()!.lines).toEqual([]); + f.send({ + type: "diagnostics", + diagnostics: [ + { + path: "src/main.ts", + line: 3, + column: 2, + severity: "error", + message: "fixture diagnostic", + }, + ], + }); + f.send({ + type: "testResults", + results: { + tests: [ + { + suite: "suite", + case: "failed", + status: "failed", + invocations: 1, + path: "src/main.ts", + line: 3, + }, + ], + summary: { total: 1, passed: 0, failed: 1, errors: 0, skipped: 0 }, + success: false, + }, + }); + f.terminal.resolve({ kind: "failed", exitCode: 1 }); + await operation; + expect(f.state()!.diagnostics[0]!.message).toBe("fixture diagnostic"); + expect(f.state()!.testResults!.summary.failed).toBe(1); + }); +} +test("malformed native payloads cannot crash ingestion or manufacture success", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + for (const payload of [ + { type: "diagnostics", diagnostics: [{ path: null, message: "bad", line: 1 }] }, + { type: "testResults", results: { tests: [{ suite: null }], summary: {}, success: false } }, + { type: "terminal", terminal: { kind: "succeeded", message: {} } }, + { type: "chunk", stream: "stdout", text: 12 }, + ]) + f.sinks[0]!({ ...f.identity, ...payload } as unknown as BuildOutputEvent); + expect(f.state()!.status).toBe("running"); + expect(f.state()!.diagnostics).toEqual([]); + expect(f.state()!.testResults).toBeNull(); + f.terminal.resolve({ kind: "succeeded", exitCode: 0 }); + await operation; +}); +for (const change of [ + "workspace", + "generation", + "selection", + "disable", + "reload", + "window", + "unmount", +] as const) { + test(`${change} invalidates open consent before a stale approval can execute`, async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + if (change === "workspace") + workspaceRuntimeRegistry.activateWorkspace( + { id: "other", name: "Other", path: "D:/fixture/other" }, + "ready", + ); + if (change === "generation") f.store.setState({ generation: 8 }); + if (change === "selection") f.store.getState().actions.selectInstance(VIEW, "nested"); + if (change === "disable") f.store.getState().actions.dropPlugin(PLUGIN); + if (change === "reload") f.store.getState().actions.beginRequest(); + if (change === "window") { + f.native.windowLabel = () => "workspace-other"; + f.store.setState({}); + } + if (change === "unmount") f.release(); + f.session.decide(true); + await operation; + expect(f.native.execute).toHaveBeenCalledTimes(0); + expect(f.native.cancel).toHaveBeenCalledWith(f.review.operationId); + }); +} +test("unmount before a late prepare cancels the newly returned native slot without opening approval", async () => { + const f = fixture(); + const gate = deferred(f.review); + f.native.prepare = mock(() => gate.promise); + const operation = f.start(); + await flush(); + f.release(); + gate.resolve(f.review); + await operation; + expect(f.session.getSnapshot().review).toBeNull(); + expect(f.native.execute).toHaveBeenCalledTimes(0); + expect(f.native.cancel).toHaveBeenCalledTimes(1); +}); +test("unmount while subscribing releases a late listener and never invokes execute", async () => { + const f = fixture(); + const gate = deferred<() => void>(f.unlisten); + f.native.listen = mock(() => gate.promise); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + f.release(); + gate.resolve(f.unlisten); + await operation; + expect(f.unlisten).toHaveBeenCalledTimes(1); + expect(f.native.execute).toHaveBeenCalledTimes(0); +}); +test("native modelReloadRequired remains actionable and cannot reach execute", async () => { + const f = fixture(); + f.native.prepare = mock(async () => { + throw new Error("plugin.build.modelReloadRequired"); + }); + await f.start(); + expect(f.session.getSnapshot().error).toContain("modelReloadRequired"); + expect(f.native.execute).toHaveBeenCalledTimes(0); +}); +test("save failure prevents synchronization and preparation", async () => { + const f = fixture(); + f.native.save = mock(async () => { + throw new Error("fixture unsaved buffer"); + }); + await f.start(); + expect(f.native.sync).toHaveBeenCalledTimes(0); + expect(f.native.prepare).toHaveBeenCalledTimes(0); + expect(f.session.getSnapshot().error).toContain("unsaved"); +}); +test("a mismatched native review is disposed rather than presented for approval", async () => { + const f = fixture(); + f.native.prepare = mock(async () => ({ ...f.review, root: "other" })); + await f.start(); + expect(f.session.getSnapshot().review).toBeNull(); + expect(f.native.execute).toHaveBeenCalledTimes(0); + expect(f.native.cancel).toHaveBeenCalledTimes(1); + expect(f.session.getSnapshot().error).toContain("staleContext"); +}); diff --git a/windows/tauri/src/features/build-systems/build-run-session.ts b/windows/tauri/src/features/build-systems/build-run-session.ts new file mode 100644 index 0000000000..f2baca0a68 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-run-session.ts @@ -0,0 +1,586 @@ +import type { StoreApi } from "zustand"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { + BUILD_SYSTEMS_STORE_KEY, + buildSystemViewId, + useBuildSystemsStore, + type BuildSystemsState, + type BuildRequestVersion, +} from "./build-systems-store"; +import { buildRunNative, type BuildRunNative } from "./build-run-native"; +import type { BuildRunDefinition, BuildRunReview, BuildRunSelection } from "./build-run-types"; +import { + MAX_OUTPUT_SESSIONS, + useBuildOutputStore, + type BuildOutputState, +} from "./output/build-output-store"; +import { + buildOutputSessionId, + type BuildOperationIdentity, + type BuildOutputEvent, + type BuildOutputPort, + type BuildRunTerminal, +} from "./output/build-output-types"; + +export interface BuildRunState { + phase: + | "idle" + | "preparing" + | "review" + | "starting" + | "running" + | "stopping" + | "cancelFailed" + | "failed" + | "ended"; + review: BuildRunReview | null; + programs: unknown[]; + error: string | null; +} +interface Attempt { + controller: AbortController; + review: BuildRunReview | null; + decide: ((accepted: boolean) => void) | null; + executed: boolean; + settled: boolean; + finished: boolean; + terminalEvent: boolean; + cancellation: Promise | null; + cleanupError: string | null; + request: BuildRequestVersion | null; + sequence: number; + generation: number | null; + fingerprint: string | null | undefined; + syncDigest: string | null | undefined; + stale: boolean | undefined; + revocation: number; + syncing: boolean; + window: string; +} +const message = (error: unknown) => (error instanceof Error ? error.message : String(error)); +const identityKeys = [ + "operationId", + "pluginId", + "buildSystemId", + "workspaceRoot", + "root", + "generation", +] as const; +/** A successful invoke may arrive ahead of queued WebView output events. */ +export const OUTPUT_DRAIN_TIMEOUT_MS = 5000; +export function matchesBuildRun( + identity: BuildOperationIdentity, + value: unknown, +): value is BuildOutputEvent { + if (!value || typeof value !== "object") return false; + const row = value as BuildOutputEvent; + if (!identityKeys.every((key) => row[key] === identity[key])) return false; + switch (row.type) { + case "chunk": + return (row.stream === "stdout" || row.stream === "stderr") && typeof row.text === "string"; + case "diagnostics": + return ( + Array.isArray(row.diagnostics) && + row.diagnostics.every( + (value) => + value && + typeof value.path === "string" && + typeof value.message === "string" && + Number.isSafeInteger(value.line) && + value.line > 0 && + (value.column == null || (Number.isSafeInteger(value.column) && value.column > 0)) && + ["error", "warning", "information", "hint"].includes(value.severity), + ) + ); + case "testResults": + return ( + !!row.results && + Array.isArray(row.results.tests) && + row.results.tests.every( + (value) => + value && + typeof value.suite === "string" && + typeof value.case === "string" && + ["passed", "failed", "error", "skipped"].includes(value.status) && + (value.message == null || typeof value.message === "string") && + (value.path == null || typeof value.path === "string") && + Number.isSafeInteger(value.invocations) && + value.invocations >= 0, + ) && + !!row.results.summary && + ["total", "passed", "failed", "errors", "skipped"].every((key) => { + const value = row.results.summary[key as keyof typeof row.results.summary]; + return Number.isSafeInteger(value) && value >= 0; + }) && + typeof row.results.success === "boolean" + ); + case "terminal": + return validTerminal(row.terminal); + default: + return false; + } +} +function validTerminal(value: BuildRunTerminal): boolean { + return ( + !!value && + ["succeeded", "failed", "cancelled", "failedToStart"].includes(value.kind) && + (value.message == null || typeof value.message === "string") && + (value.exitCode == null || Number.isSafeInteger(value.exitCode)) + ); +} +async function abortable(work: Promise, signal: AbortSignal): Promise { + let abort = () => {}; + const cancelled = new Promise((_resolve, reject) => { + abort = () => reject(new Error("plugin.build.staleContext")); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + }); + try { + return await Promise.race([work, cancelled]); + } finally { + signal.removeEventListener("abort", abort); + } +} + +/** Frontend handle to one native owner. No executable resolution, process + * lifecycle, output decoding or build semantics are implemented here. A failed + * cleanup retains this handle across panel reopening so Stop remains usable. */ +export class BuildRunSession { + readonly sessionId: string; + readonly port: BuildOutputPort; + #state: BuildRunState = { phase: "idle", review: null, programs: [], error: null }; + #listeners = new Set<() => void>(); + #attempt: Attempt | null = null; + #attached = 0; + #releaseContext: (() => void) | null = null; + #releaseEvents: (() => void) | null = null; + #listening: Promise | null = null; + #eventEpoch = 0; + #finishDrain: (() => void) | null = null; + #output: StoreApi; + constructor( + readonly workspaceId: string, + readonly pluginId: string, + readonly buildSystemId: string, + readonly root: string, + private store: StoreApi, + readonly native: BuildRunNative = buildRunNative, + sessionId = buildOutputSessionId(pluginId, buildSystemId, root), + ) { + this.sessionId = sessionId; + this.#output = useBuildOutputStore.getStore(workspaceId); + this.port = { + subscribe: async (identity, sink) => + native.listen(native.windowLabel(), (event) => { + if (matchesBuildRun(identity, event)) sink(event); + }), + cancel: (identity) => this.cancel(identity), + }; + } + getSnapshot = () => this.#state; + subscribe = (listener: () => void) => { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + }; + get canEvict() { + return !this.#attached && !this.#attempt; + } + #emit(patch: Partial) { + this.#state = { ...this.#state, ...patch }; + for (const listener of this.#listeners) listener(); + } + #instance() { + return this.store + .getState() + .windows[buildSystemViewId(this.pluginId, this.buildSystemId)]?.instances.find( + (row) => row.root === this.root, + ); + } + #current(attempt: Attempt) { + const state = this.store.getState(); + const runtime = workspaceRuntimeRegistry.getWorkspace(this.workspaceId); + return ( + !attempt.controller.signal.aborted && + this.#attached > 0 && + this.native.windowLabel() === attempt.window && + workspaceRuntimeRegistry.getActiveWorkspaceId() === this.workspaceId && + runtime?.stores.get(BUILD_SYSTEMS_STORE_KEY) === this.store && + runtime.status === "ready" && + runtime.descriptor.path === state.workspaceRoot && + !state.revokedPlugins[this.pluginId] && + (state.pluginRevocationEpochs[this.pluginId] ?? 0) === attempt.revocation && + state.windows[buildSystemViewId(this.pluginId, this.buildSystemId)]?.selectedRoot === + this.root && + state.requestSequence === attempt.sequence && + (attempt.syncing || + (state.generation === attempt.generation && + this.#instance()?.optionFingerprint === attempt.fingerprint && + this.#instance()?.syncDigest === attempt.syncDigest && + this.#instance()?.stale === attempt.stale)) + ); + } + attach(): () => void { + this.#attached += 1; + if (this.#attached === 1) { + const check = () => { + const attempt = this.#attempt; + if (attempt && !attempt.controller.signal.aborted && !this.#current(attempt)) { + this.#closeEvents(); + void this.cancel().catch(() => {}); + } + const generation = this.store.getState().generation; + if (generation !== null) this.#output.getState().actions.invalidateGeneration(generation); + }; + const releaseStore = this.store.subscribe(check); + const releaseRegistry = workspaceRuntimeRegistry.subscribe(check); + this.#releaseContext = () => { + releaseStore(); + releaseRegistry(); + }; + const session = this.#output.getState().sessions[this.sessionId]; + if (session?.status === "running") + void this.#listen(session.identity).catch((error) => + this.#output + .getState() + .actions.subscriptionFailed(this.sessionId, session.identity, message(error)), + ); + } + let released = false; + return () => { + if (released) return; + released = true; + if (--this.#attached) return; + this.#releaseContext?.(); + this.#releaseContext = null; + this.#closeEvents(); + if (this.#attempt) void this.cancel().catch(() => {}); + }; + } + #closeEvents() { + this.#finishDrain?.(); + this.#eventEpoch += 1; + this.#releaseEvents?.(); + this.#releaseEvents = null; + this.#listening = null; + } + async #listen(identity: BuildOperationIdentity): Promise { + if (this.#releaseEvents) return; + if (this.#listening) return this.#listening; + const epoch = this.#eventEpoch; + const windowLabel = this.native.windowLabel(); + this.#listening = this.native + .listen(windowLabel, (event) => { + if ( + epoch !== this.#eventEpoch || + !this.#attached || + this.native.windowLabel() !== windowLabel || + !matchesBuildRun(identity, event) + ) + return; + const actions = this.#output.getState().actions; + switch (event.type) { + case "chunk": + actions.appendChunk(this.sessionId, event, event.stream, event.text); + break; + case "diagnostics": + actions.applyDiagnostics(this.sessionId, event, event.diagnostics); + break; + case "testResults": + actions.applyTestResults(this.sessionId, event, event.results); + break; + case "terminal": + actions.complete(this.sessionId, event, event.terminal); + if (this.#attempt?.review?.operationId === identity.operationId) { + const attempt = this.#attempt; + attempt.terminalEvent = true; + attempt.finished = true; + if (attempt.settled) { + if (attempt.request) this.store.getState().actions.cancelRequest(attempt.request); + this.#attempt = null; + this.#closeEvents(); + this.#emit({ phase: "ended", review: null, error: null }); + } + } + this.#finishDrain?.(); + break; + } + }) + .then((release) => { + if (epoch !== this.#eventEpoch || !this.#attached) release(); + else this.#releaseEvents = release; + }); + return this.#listening; + } + async #drainEvents(attempt: Attempt): Promise { + // Invalidation closes the owned listener before native execution returns. + // No event can drain after that point; the invoke still proves cleanup. + if (attempt.terminalEvent || !this.#attached || attempt.controller.signal.aborted) return; + await new Promise((resolve) => { + let cancelTimer = () => {}; + const finish = () => { + cancelTimer(); + this.#finishDrain = null; + resolve(); + }; + this.#finishDrain = finish; + const timeout = () => { + if (attempt.review) + this.#output + .getState() + .actions.subscriptionFailed( + this.sessionId, + attempt.review, + "plugin.build.outputUnavailable", + ); + finish(); + }; + const schedule = + this.native.scheduleOutputDrain ?? + ((callback: () => void, milliseconds: number) => { + const timer = setTimeout(callback, milliseconds); + return () => clearTimeout(timer); + }); + cancelTimer = schedule(timeout, OUTPUT_DRAIN_TIMEOUT_MS); + }); + } + decide(accepted: boolean) { + const decide = this.#attempt?.decide; + if (this.#attempt) this.#attempt.decide = null; + if (decide) this.#emit({ review: null, phase: accepted ? "starting" : "stopping" }); + decide?.(accepted); + } + async start(selection: BuildRunSelection): Promise { + if (this.#attempt || this.#output.getState().sessions[this.sessionId]?.status === "running") + return; + const state = this.store.getState(); + const attempt: Attempt = { + controller: new AbortController(), + review: null, + decide: null, + executed: false, + settled: false, + finished: false, + terminalEvent: false, + cancellation: null, + cleanupError: null, + request: null, + sequence: state.requestSequence, + generation: state.generation, + fingerprint: this.#instance()?.optionFingerprint, + syncDigest: this.#instance()?.syncDigest, + stale: this.#instance()?.stale, + revocation: state.pluginRevocationEpochs[this.pluginId] ?? 0, + syncing: false, + window: this.native.windowLabel(), + }; + this.#attempt = attempt; + this.#emit({ phase: "preparing", error: null, review: null, programs: [] }); + const current = () => { + if (!this.#current(attempt)) throw new Error("plugin.build.staleContext"); + }; + try { + current(); + await abortable(this.native.save(this.workspaceId), attempt.controller.signal); + current(); + attempt.syncing = true; + attempt.sequence += 1; + await abortable( + this.native.sync(this.pluginId, this.buildSystemId, this.root), + attempt.controller.signal, + ); + attempt.generation = this.store.getState().generation; + attempt.fingerprint = this.#instance()?.optionFingerprint; + attempt.syncDigest = this.#instance()?.syncDigest; + attempt.stale = this.#instance()?.stale; + attempt.syncing = false; + current(); + // Reuse the build store's busy barrier for explicit edits/model loads. + // Passive fingerprint observations still run while busy, and invalidate + // consent only when the source context actually changed. + attempt.sequence += 1; + attempt.request = this.store.getState().actions.beginRequest(); + current(); + const definition: BuildRunDefinition = await this.native.definition( + this.pluginId, + this.buildSystemId, + ); + current(); + // A late prepare may allocate a native review after unmount. Always + // retain its ID and cancel it; never race this await against disposal. + const review = await this.native.prepare( + this.store.getState().workspaceRoot!, + this.pluginId, + this.buildSystemId, + this.root, + selection, + ); + attempt.review = review; + current(); + this.native.validateReview?.(review, selection); + if ( + review.pluginId !== this.pluginId || + review.buildSystemId !== this.buildSystemId || + review.root !== this.root || + review.workspaceRoot !== this.store.getState().workspaceRoot || + review.generation !== attempt.generation || + review.kind !== selection.kind || + !review.operationId + ) + throw new Error("plugin.build.staleContext"); + this.#emit({ phase: "review", review, programs: definition.programs }); + const accepted = await new Promise((resolve) => { + attempt.decide = resolve; + }); + attempt.decide = null; + if (!accepted) return; + current(); + const identity: BuildOperationIdentity = { ...review, title: review.title }; + if (!this.#output.getState().actions.beginOperation(this.sessionId, identity)) + throw new Error("plugin.build.busy"); + this.#closeEvents(); + await this.#listen(identity); + current(); + // A file notification may still be in the watcher's debounce window. + // Acquire the output listener first, then recheck the native fingerprint + // immediately before execution. Preserve the approved snapshot rather + // than adopting a changed model or option state. This check never + // executes project/tool code. + attempt.syncing = true; + attempt.sequence += 1; + await abortable( + this.native.sync(this.pluginId, this.buildSystemId, this.root), + attempt.controller.signal, + ); + attempt.syncing = false; + if (!attempt.stale && this.#instance()?.stale) + throw new Error("plugin.build.modelReloadRequired"); + current(); + attempt.sequence += 1; + attempt.request = this.store.getState().actions.beginRequest(); + current(); + this.#emit({ phase: "running", review: null }); + attempt.executed = true; + const terminal = await this.native.execute(review.operationId); + if (!validTerminal(terminal)) throw new Error("plugin.build.invalidOutput"); + attempt.finished = true; + await this.#drainEvents(attempt); + // invoke's result is authoritative after cleanup, even when the panel + // detached before the final event. Store identity guards a replacement. + if ( + workspaceRuntimeRegistry + .getWorkspace(this.workspaceId) + ?.stores.get(BUILD_SYSTEMS_STORE_KEY) === this.store + ) + this.#output.getState().actions.complete(this.sessionId, identity, terminal); + } catch (error) { + if (attempt.executed && attempt.review && !attempt.finished) { + this.#output.getState().actions.stopFailed(this.sessionId, attempt.review, message(error)); + this.#emit({ phase: "cancelFailed", review: null, error: message(error) }); + return; // An execute rejection cannot prove native cleanup. + } + if (!attempt.executed && !attempt.controller.signal.aborted) + this.#emit({ phase: "failed", review: null, error: message(error) }); + } finally { + attempt.settled = true; + attempt.controller.abort(); + attempt.decide?.(false); + attempt.decide = null; + if (!attempt.executed && attempt.review && !attempt.cleanupError) { + try { + await this.#cancelNative(attempt); + } catch { + /* cancel() retained the error and native owner for retry. */ + } + } + const retained = + !attempt.finished && (attempt.executed || this.#state.phase === "cancelFailed"); + if (!retained && this.#attempt === attempt) { + if (attempt.request) this.store.getState().actions.cancelRequest(attempt.request); + this.#attempt = null; + this.#closeEvents(); + this.#emit({ + review: null, + phase: this.#state.phase === "failed" ? "failed" : "ended", + error: this.#state.phase === "failed" ? this.#state.error : null, + }); + } + } + } + async #cancelNative(attempt: Attempt): Promise { + if (!attempt.review || attempt.finished) return; + if (attempt.cancellation) return attempt.cancellation; + const review = attempt.review; + attempt.cancellation = Promise.resolve() + .then(() => this.native.cancel(review.operationId)) + .catch((error) => { + attempt.cancellation = null; + attempt.cleanupError = message(error); + if (this.#attempt === attempt && !attempt.finished) { + this.#output.getState().actions.stopFailed(this.sessionId, review, message(error)); + this.#emit({ phase: "cancelFailed", review: null, error: message(error) }); + } + throw error; + }); + await attempt.cancellation; + attempt.cancellation = null; + if (this.#attempt !== attempt) return; + if (!attempt.executed || attempt.settled) { + attempt.finished = true; + // A review never executed, or execute already rejected. This native + // cancel result confirms cleanup; a pending execute still owns its result. + this.#output.getState().actions.complete(this.sessionId, review, { kind: "cancelled" }); + if (attempt.request) this.store.getState().actions.cancelRequest(attempt.request); + if (this.#attempt === attempt) this.#attempt = null; + this.#closeEvents(); + this.#emit({ + phase: this.#state.phase === "failed" ? "failed" : "ended", + review: null, + error: this.#state.phase === "failed" ? this.#state.error : null, + }); + } + } + async cancel(identity?: BuildOperationIdentity): Promise { + const attempt = this.#attempt; + if ( + identity && + (!attempt?.review || !identityKeys.every((key) => attempt.review![key] === identity[key])) + ) + throw new Error("plugin.build.staleContext"); + if (!attempt) return; + attempt.cleanupError = null; + attempt.controller.abort(); + attempt.decide?.(false); + attempt.decide = null; + this.#emit({ phase: "stopping", review: null, error: null }); + if (attempt.review) this.#output.getState().actions.beginStop(this.sessionId, attempt.review); + if (attempt.review) await this.#cancelNative(attempt); + } +} + +// Bounded UI handles, keyed by the actual workspace store. Native owns the +// operations; retained handles exist solely to retry a failed cleanup. +const sessions = new WeakMap, Map>(); +export function getBuildRunSession( + workspaceId: string, + pluginId: string, + buildSystemId: string, + root: string, + native: BuildRunNative = buildRunNative, +): BuildRunSession { + const store = useBuildSystemsStore.getStore(workspaceId); + const rows = sessions.get(store) ?? new Map(); + sessions.set(store, rows); + const key = buildOutputSessionId(pluginId, buildSystemId, root); + const existing = rows.get(key); + if (existing) return existing; + if (rows.size >= MAX_OUTPUT_SESSIONS) { + const free = [...rows].find(([, session]) => session.canEvict); + if (!free) throw new Error("plugin.build.busy"); + rows.delete(free[0]); + } + const session = new BuildRunSession(workspaceId, pluginId, buildSystemId, root, store, native); + rows.set(key, session); + return session; +} diff --git a/windows/tauri/src/features/build-systems/build-run-types.ts b/windows/tauri/src/features/build-systems/build-run-types.ts new file mode 100644 index 0000000000..33f7144982 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-run-types.ts @@ -0,0 +1,40 @@ +import type { BuildModelReview } from "./build-systems-types"; + +/** Common operation selection. Task/test fields mirror native serde; the + * dependency adapter narrows its request to projectId before invoking. */ +export interface BuildRunSelection { + kind: "task" | "test" | "dependencies"; + taskId: string | null; + projectId: string | null; + targetId: string | null; + modelTaskId: string | null; + suite: string | null; + case: string | null; + userArguments: string[]; +} +export interface BuildRunReview extends BuildModelReview { + title: string; + kind: "task" | "test" | "dependencies"; +} +export type BuildRunScope = "workspace" | "project" | "target"; +export interface BuildTaskDeclaration { + id: string; + displayName: string; + execution?: "task" | "application" | "service"; + scopes?: BuildRunScope[]; + targetTags?: string[]; + fromModel?: boolean; + requiresUserArguments?: boolean; + category?: string[]; + group?: string; +} +export interface BuildRunDefinition { + dependencies?: { + source: "command" | "hostIntegration" | "provider"; + scopes?: ("workspace" | "project")[]; + } | null; + tasks: BuildTaskDeclaration[]; + test?: { scopes: BuildRunScope[]; targetTags?: string[]; filters?: ("suite" | "case")[] } | null; + /** Full Core-reviewed program declarations, never a guessed command/path. */ + programs: unknown[]; +} diff --git a/windows/tauri/src/features/build-systems/build-systems-service.test.ts b/windows/tauri/src/features/build-systems/build-systems-service.test.ts new file mode 100644 index 0000000000..ffd6ae9d56 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-systems-service.test.ts @@ -0,0 +1,1257 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import * as eventApi from "@tauri-apps/api/event"; +import * as lspAdapter from "@/platform/lsp-core-adapter"; +import type { BuildModelReview, BuildView } from "./build-systems-types"; + +/** + * Service tests for the declarative build bridge. Every request captures + * its initiating workspace's store and sequence; these tests pin the + * staleness contract across workspace switches, out-of-order responses, + * late failures, and plugin revocation. All waits are deferred promises the + * test resolves; nothing polls or sleeps. + */ + +const pendingDeferred = new Set(); +const cleanups: Array<() => void> = []; + +interface Deferred { + promise: Promise; + resolve: (value: unknown) => void; + reject: (error: unknown) => void; +} + +function deferred(): Deferred { + let resolve!: (value: unknown) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res as (value: unknown) => void; + reject = rej; + }); + const result = { promise, resolve, reject }; + pendingDeferred.add(result); + return result; +} + +const invokeCalls: Array<{ command: string; args: Record }> = []; +const deferredByCommand = new Map(); +let nextResult: unknown = null; +let nextCatalog: unknown = []; +let nextError: Error | null = null; + +function deferCommand(command: string): Deferred { + const entry = deferred(); + const queue = deferredByCommand.get(command) ?? []; + queue.push(entry); + deferredByCommand.set(command, queue); + return entry; +} + +const invokeMock = mock(async (command: string, args?: Record) => { + invokeCalls.push({ command, args: args ?? {} }); + const queue = deferredByCommand.get(command); + if (queue && queue.length > 0) { + return queue.shift()!.promise; + } + if (command === "declarative_plugins_build_catalog") { + return nextCatalog; + } + if (nextError) { + throw nextError; + } + return nextResult; +}); +mock.module("@/platform/tauri-core", () => ({ invoke: invokeMock })); +const sharedLanguageStart = mock( + async (_args: unknown, _signal?: AbortSignal): Promise => {}, +); +mock.module("@/platform/lsp-core-adapter", () => ({ + ...lspAdapter, + ensureBuildProviderLanguageSession: sharedLanguageStart, +})); + +type LifecycleHandler = (event: { payload: unknown }) => void; +let lifecycleHandler: LifecycleHandler | null = null; +let fileChangeHandler: LifecycleHandler | null = null; + +mock.module("@tauri-apps/api/event", () => ({ + // Keep unrelated named exports available when the pane tests share Bun's + // module graph; only the subscription boundary belongs to this test double. + ...eventApi, + listen: mock(async (event: string, handler: LifecycleHandler) => { + if (event === "declarative-plugin-lifecycle") { + lifecycleHandler = handler; + } else if (event === "ln") { + fileChangeHandler = handler; + } + return () => { + if (event === "declarative-plugin-lifecycle" && lifecycleHandler === handler) { + lifecycleHandler = null; + } + if (event === "ln" && fileChangeHandler === handler) { + fileChangeHandler = null; + } + }; + }), +})); + +mock.module("@tauri-apps/api/window", () => ({ + // Co-running suites in one bun process also resolve types and classes + // from this module; provide the named exports they import so the mock + // stays import-compatible outside this file. + getCurrentWindow: () => ({ label: "test-window" }), + Window: class Window {}, + WebviewWindow: class WebviewWindow {}, + getAllWindows: async () => [], +})); + +const { workspaceRuntimeRegistry } = + await import("@/features/workspace/runtime/workspace-runtime-registry"); +const buildSystemsService = await import("./build-systems-service"); +const { + refreshBuildSystems, + setBuildOption, + subscribeBuildSyncWatcher, + startBuildSystemsRuntime, + useBuildSystemsCatalogStore, +} = buildSystemsService; +const { useBuildSystemsStore } = await import("./build-systems-store"); +const { BuildRunSession } = await import("./build-run-session"); +type BuildRunNative = import("./build-run-native").BuildRunNative; +type BuildRunTerminal = import("./output/build-output-types").BuildRunTerminal; + +const WORKSPACE_A = "C:/work/a"; +const WORKSPACE_B = "C:/work/b"; + +function activateWorkspace(id: string, path: string | null): void { + if (path) { + workspaceRuntimeRegistry.activateWorkspace({ id, name: id, path }, "ready"); + } else { + workspaceRuntimeRegistry.activateWorkspace({ id, name: "Files" }, "ready"); + } +} + +function resetState(): void { + sharedLanguageStart.mockReset().mockImplementation(async () => {}); + invokeCalls.length = 0; + deferredByCommand.clear(); + nextError = null; + nextCatalog = []; + nextResult = null; + for (const id of ["ws-a", "ws-b", "welcome"]) { + useBuildSystemsStore.getStore(id).getState().actions.reset(); + } + useBuildSystemsCatalogStore.setState({ entries: [] }); + lifecycleHandler = null; + fileChangeHandler = null; +} + +async function flushMicrotasks(): Promise { + // A fixed number of microtask turns drains the enqueue/invoke/apply chain; + // no timer, polling loop, or wall-clock wait participates in these tests. + for (let turn = 0; turn < 16; turn += 1) await Promise.resolve(); +} + +afterEach(async () => { + buildSystemsService.cleanupBuildSystemsRuntime?.(); + for (const cleanup of cleanups.splice(0)) cleanup(); + for (const pending of pendingDeferred) pending.resolve(null); + pendingDeferred.clear(); + await flushMicrotasks(); + // A pane suite may spy on this same export later in the process. + invokeMock.mockClear(); +}); + +const catalogEntry = { + pluginId: "dev.lithe.fixture.build-static-file", + pluginDisplayName: "Static File Build Fixture", + buildSystemId: "cargo", + buildSystemDisplayName: "Cargo", + icon: null, +}; + +function cargoView(workspaceRoot: string, generation: number): BuildView { + return { + generation, + workspaceRoot, + instances: [ + { + pluginID: "dev.lithe.fixture.build-static-file", + buildSystemID: "cargo", + displayName: "Cargo", + root: ".", + markers: ["Cargo.toml"], + modelSource: "static", + model: { projects: [{ name: "fixture-app", path: "." }] }, + stale: false, + syncDigest: null, + optionFingerprint: "fp", + options: [], + }, + ], + issues: [], + }; +} + +describe("file changes during task consent and execution", () => { + async function runningFixture() { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const initial = cargoView(WORKSPACE_A, 1); + workspaceRuntimeRegistry.updateWorkspaceStatus("ws-a", "ready"); + initial.instances[0]!.modelSource = "command"; + initial.instances[0]!.syncDigest = "approved-source"; + nextResult = initial; + const store = useBuildSystemsStore.getStore("ws-a"); + store.getState().actions.applyView(initial, store.getState().actions.beginRequest()); + const terminal = deferred(); + const native: BuildRunNative = { + windowLabel: () => "test-window", + save: async () => {}, + sync: buildSystemsService.syncInstance, + definition: async () => ({ + tasks: [{ id: "build", displayName: "Build", scopes: ["workspace"] }], + programs: [], + }), + prepare: async () => ({ + operationId: "native-watched-run", + pluginId: catalogEntry.pluginId, + buildSystemId: "cargo", + workspaceRoot: WORKSPACE_A, + root: ".", + generation: 1, + kind: "task", + title: "Build", + executesProjectCode: true, + mayUseNetwork: true, + }), + execute: mock(async () => terminal.promise as Promise), + cancel: mock(async () => { + terminal.resolve({ kind: "cancelled" }); + }), + listen: async () => () => {}, + scheduleOutputDrain: (callback) => { + callback(); + return () => {}; + }, + }; + const session = new BuildRunSession("ws-a", catalogEntry.pluginId, "cargo", ".", store, native); + const releaseSession = session.attach(); + cleanups.push(() => { + session.decide(false); + releaseSession(); + }); + let scheduled: (() => void) | undefined; + const releaseWatcher = await subscribeBuildSyncWatcher((callback) => { + scheduled = callback; + return () => { + scheduled = undefined; + }; + }); + cleanups.push(releaseWatcher); + const run = session.start({ + kind: "task", + taskId: "build", + projectId: null, + targetId: null, + modelTaskId: null, + suite: null, + case: null, + userArguments: [], + }); + await flushMicrotasks(); + expect(session.getSnapshot()).toEqual( + expect.objectContaining({ phase: "review", error: null }), + ); + const change = async (changed: boolean) => { + nextResult = changed + ? { + ...initial, + instances: initial.instances.map((row) => ({ + ...row, + stale: true, + syncDigest: "edited-source", + })), + } + : initial; + fileChangeHandler!({ payload: { path: "Cargo.toml", event_type: "reloaded" } }); + expect(scheduled).toBeDefined(); + scheduled!(); + await flushMicrotasks(); + }; + return { initial, native, session, store, terminal, run, change }; + } + + test("unchanged save notifications preserve consent and the active run", async () => { + const f = await runningFixture(); + await f.change(false); + expect(f.session.getSnapshot().phase).toBe("review"); + expect(f.native.cancel).toHaveBeenCalledTimes(0); + f.session.decide(true); + await flushMicrotasks(); + expect(f.native.execute).toHaveBeenCalledTimes(1); + await f.change(false); + expect(f.session.getSnapshot().phase).toBe("running"); + expect(f.native.cancel).toHaveBeenCalledTimes(0); + f.terminal.resolve({ kind: "succeeded", exitCode: 0 }); + await f.run; + }); + + test("a watched fingerprint change cancels open consent before execution", async () => { + const f = await runningFixture(); + await f.change(true); + f.session.decide(true); + await f.run; + expect(f.native.execute).toHaveBeenCalledTimes(0); + expect(f.native.cancel).toHaveBeenCalledWith("native-watched-run"); + expect( + f.store.getState().windows[`build-system:${catalogEntry.pluginId}:cargo`]!.instances[0]! + .stale, + ).toBe(true); + }); + + test("a watched fingerprint change also cancels an executing native owner", async () => { + const f = await runningFixture(); + f.session.decide(true); + await flushMicrotasks(); + expect(f.native.execute).toHaveBeenCalledTimes(1); + await f.change(true); + await f.run; + expect(f.native.cancel).toHaveBeenCalledWith("native-watched-run"); + }); + + test("approval rechecks native freshness before a debounced event is delivered", async () => { + const f = await runningFixture(); + nextResult = { + ...f.initial, + instances: f.initial.instances.map((row) => ({ + ...row, + stale: true, + syncDigest: "edited-before-watch", + })), + }; + // Deliberately leave the real file event's scheduled callback pending. + fileChangeHandler!({ payload: { path: "Cargo.toml", event_type: "reloaded" } }); + f.session.decide(true); + await f.run; + expect(f.native.execute).toHaveBeenCalledTimes(0); + expect(f.native.cancel).toHaveBeenCalledWith("native-watched-run"); + }); + + test("a file change while acquiring the output listener is checked before execution", async () => { + const f = await runningFixture(); + const listener = deferred(); + f.native.listen = mock(async () => listener.promise as Promise<() => void>); + f.session.decide(true); + await flushMicrotasks(); + expect(f.native.listen).toHaveBeenCalledTimes(1); + expect(f.native.execute).toHaveBeenCalledTimes(0); + nextResult = { + ...f.initial, + instances: f.initial.instances.map((row) => ({ + ...row, + stale: true, + syncDigest: "edited-during-listener-acquisition", + })), + }; + // The notification remains debounced while native listener acquisition + // completes. Its delivery must not be necessary to reject the old plan. + fileChangeHandler!({ payload: { path: "Cargo.toml", event_type: "reloaded" } }); + listener.resolve(() => {}); + await flushMicrotasks(); + f.terminal.resolve({ kind: "succeeded", exitCode: 0 }); + await f.run; + expect(f.native.execute).toHaveBeenCalledTimes(0); + expect(f.native.cancel).toHaveBeenCalledWith("native-watched-run"); + }); +}); + +describe("refreshBuildSystems", () => { + test("forwards the workspace root without a forgeable window label", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + nextCatalog = [catalogEntry]; + nextResult = cargoView(WORKSPACE_A, 1); + + const entries = await refreshBuildSystems(); + + expect(entries).toEqual([catalogEntry]); + const refresh = invokeCalls.find( + (call) => call.command === "declarative_plugins_build_refresh", + ); + expect(refresh?.args).toEqual({ workspaceRoot: WORKSPACE_A }); + expect(useBuildSystemsStore.getStore("ws-a").getState().status).toBe("ready"); + }); + + test("a response landing after a switch is discarded before the next workspace detects", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const pendingA = deferCommand("declarative_plugins_build_refresh"); + const refreshA = refreshBuildSystems(); + await flushMicrotasks(); + + activateWorkspace("ws-b", WORKSPACE_B); + const pendingB = deferCommand("declarative_plugins_build_refresh"); + const refreshB = refreshBuildSystems(); + + pendingA.resolve(cargoView(WORKSPACE_A, 1)); + await refreshA; + await flushMicrotasks(); + pendingB.resolve(cargoView(WORKSPACE_B, 2)); + await refreshB; + + // A's old activation cannot publish a ready native session after a + // switch. B is dispatched after A finishes and owns the final session. + expect(useBuildSystemsStore.getStore("ws-a").getState().workspaceRoot).toBe(null); + expect(useBuildSystemsStore.getStore("ws-b").getState().generation).toBe(2); + expect(useBuildSystemsStore.getStore("ws-b").getState().workspaceRoot).toBe(WORKSPACE_B); + }); + + test("returning to a workspace drops the older of two in-flight refreshes", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const first = deferCommand("declarative_plugins_build_refresh"); + const refresh1 = refreshBuildSystems(); + await flushMicrotasks(); + + activateWorkspace("ws-b", WORKSPACE_B); + const refreshB = refreshBuildSystems(); + activateWorkspace("ws-a", WORKSPACE_A); + const second = deferCommand("declarative_plugins_build_refresh"); + const refresh2 = refreshBuildSystems(); + + first.resolve(cargoView(WORKSPACE_A, 4)); + await refresh1; + await refreshB; + expect(useBuildSystemsStore.getStore("ws-a").getState().generation).toBeNull(); + await flushMicrotasks(); + second.resolve(cargoView(WORKSPACE_A, 5)); + await refresh2; + + // The second request carried a newer sequence; the first is a late + // response and must not overwrite it. + expect(useBuildSystemsStore.getStore("ws-a").getState().generation).toBe(5); + }); + + test("a failed request does not strand the next queued refresh", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const failing = deferCommand("declarative_plugins_build_refresh"); + const refresh1 = refreshBuildSystems(); + await flushMicrotasks(); + + nextCatalog = []; + nextResult = cargoView(WORKSPACE_A, 2); + const refresh2 = refreshBuildSystems(); + + failing.reject(new Error("plugin.build.invalidSelection: late boom")); + await refresh1; + await refresh2; + + const state = useBuildSystemsStore.getStore("ws-a").getState(); + expect(state.status).toBe("ready"); + expect(state.error).toBeNull(); + }); + + test("a failure for the latest sequence surfaces in the store", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + nextError = new Error("plugin.build.invalidSelection: boom"); + await refreshBuildSystems(); + + const state = useBuildSystemsStore.getStore("ws-a").getState(); + expect(state.status).toBe("failed"); + expect(state.error).toContain("plugin.build.invalidSelection"); + }); + + test("without a workspace the store resets and the host is not called", async () => { + resetState(); + activateWorkspace("welcome", null); + + await refreshBuildSystems(); + + expect( + invokeCalls.find((call) => call.command === "declarative_plugins_build_refresh"), + ).toBeUndefined(); + expect(useBuildSystemsStore.getStore("welcome").getState().status).toBe("idle"); + }); +}); + +describe("setBuildOption", () => { + test("forwards the edit and applies the returned view", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + nextResult = cargoView(WORKSPACE_A, 1); + await refreshBuildSystems(); + invokeCalls.length = 0; + nextResult = cargoView(WORKSPACE_A, 2); + + await setBuildOption("dev.lithe.fixture.build-static-file", "cargo", ".", "release", true); + + const edit = invokeCalls[0]!; + expect(edit.command).toBe("declarative_plugins_build_set_option"); + expect(edit.args).toEqual({ + workspaceRoot: WORKSPACE_A, + pluginId: "dev.lithe.fixture.build-static-file", + buildSystemId: "cargo", + root: ".", + optionId: "release", + value: true, + }); + expect(useBuildSystemsStore.getStore("ws-a").getState().generation).toBe(2); + }); + + test("a late edit failure lands in the initiating store with its sequence", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + nextResult = cargoView(WORKSPACE_A, 1); + await refreshBuildSystems(); + + const pending = deferCommand("declarative_plugins_build_set_option"); + const edit = setBuildOption( + "dev.lithe.fixture.build-static-file", + "cargo", + ".", + "release", + true, + ).catch(() => {}); + pending.reject(new Error("rejected by Core")); + await edit; + + const state = useBuildSystemsStore.getStore("ws-a").getState(); + expect(state.status).toBe("failed"); + expect(state.error).toContain("rejected by Core"); + }); +}); + +describe("plugin revocation", () => { + test("the lifecycle listener filters the catalog and revokes the registry", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + nextCatalog = [catalogEntry]; + nextResult = cargoView(WORKSPACE_A, 1); + await refreshBuildSystems(); + expect(useBuildSystemsCatalogStore.getState().entries).toEqual([catalogEntry]); + const background = useBuildSystemsStore.getStore("ws-b"); + const backgroundRequest = background.getState().actions.beginRequest(); + background.getState().actions.applyView(cargoView(WORKSPACE_B, 1), backgroundRequest); + + invokeCalls.length = 0; + await startBuildSystemsRuntime(); + expect(lifecycleHandler).not.toBeNull(); + lifecycleHandler!({ + payload: { pluginId: "dev.lithe.fixture.build-static-file", active: false }, + }); + // Revoke runs async; flush the microtask queue. + await flushMicrotasks(); + + expect(useBuildSystemsCatalogStore.getState().entries).toEqual([]); + expect(useBuildSystemsStore.getStore("ws-a").getState().windows).toEqual({}); + expect( + useBuildSystemsStore.getStore("ws-b").getState().windows, + "non-active workspace stores are revoked too", + ).toEqual({}); + const revoke = invokeCalls.find( + (call) => call.command === "declarative_plugins_build_plugins_changed", + ); + expect(revoke?.args).toEqual({ + pluginId: "dev.lithe.fixture.build-static-file", + }); + buildSystemsService.cleanupBuildSystemsRuntime?.(); + }); + + test("refresh, option, and sync responses started before disable cannot revive the plugin", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + nextCatalog = [catalogEntry]; + nextResult = cargoView(WORKSPACE_A, 1); + const cleanupRuntime = await startBuildSystemsRuntime(); + await flushMicrotasks(); + + const refreshResponse = deferCommand("declarative_plugins_build_refresh"); + const refresh = refreshBuildSystems(); + await flushMicrotasks(); + expect(invokeCalls[invokeCalls.length - 1]?.command).toBe("declarative_plugins_build_refresh"); + lifecycleHandler!({ + payload: { pluginId: "dev.lithe.fixture.build-static-file", active: false }, + }); + await flushMicrotasks(); + refreshResponse.resolve(cargoView(WORKSPACE_A, 2)); + await refresh; + expect(useBuildSystemsStore.getStore("ws-a").getState().windows).toEqual({}); + + lifecycleHandler!({ + payload: { pluginId: "dev.lithe.fixture.build-static-file", active: true }, + }); + nextResult = cargoView(WORKSPACE_A, 3); + await refreshBuildSystems(); + const optionResponse = deferCommand("declarative_plugins_build_set_option"); + const option = setBuildOption( + "dev.lithe.fixture.build-static-file", + "cargo", + ".", + "release", + true, + ); + await flushMicrotasks(); + expect(invokeCalls[invokeCalls.length - 1]?.command).toBe( + "declarative_plugins_build_set_option", + ); + lifecycleHandler!({ + payload: { pluginId: "dev.lithe.fixture.build-static-file", active: false }, + }); + await flushMicrotasks(); + optionResponse.resolve(cargoView(WORKSPACE_A, 4)); + await option; + expect(useBuildSystemsStore.getStore("ws-a").getState().windows).toEqual({}); + + lifecycleHandler!({ + payload: { pluginId: "dev.lithe.fixture.build-static-file", active: true }, + }); + nextResult = cargoView(WORKSPACE_A, 5); + await refreshBuildSystems(); + let scheduledCheck: (() => void) | null = null; + const releaseWatcher = await subscribeBuildSyncWatcher((callback) => { + scheduledCheck = callback; + return () => { + scheduledCheck = null; + }; + }); + cleanups.push(releaseWatcher); + const syncResponse = deferCommand("declarative_plugins_build_sync_check"); + fileChangeHandler!({ payload: { path: "Cargo.toml", event_type: "reloaded" } }); + scheduledCheck!(); + await flushMicrotasks(); + expect(invokeCalls[invokeCalls.length - 1]?.command).toBe( + "declarative_plugins_build_sync_check", + ); + lifecycleHandler!({ + payload: { pluginId: "dev.lithe.fixture.build-static-file", active: false }, + }); + await flushMicrotasks(); + syncResponse.resolve(cargoView(WORKSPACE_A, 6)); + await flushMicrotasks(); + expect(useBuildSystemsStore.getStore("ws-a").getState().windows).toEqual({}); + + cleanupRuntime(); + }); + + test("a catalog fetch that started before a disable never overwrites it", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const staleFetch = deferCommand("declarative_plugins_build_catalog"); + const fetchPromise = refreshBuildSystems(); + + // The post-disable catalog lands first with the plugin removed. + nextCatalog = []; + const { fetchBuildCatalog: fetchAgain } = await import("./build-systems-service"); + const fresh = await fetchAgain(); + expect(fresh).toEqual([]); + expect(useBuildSystemsCatalogStore.getState().entries).toEqual([]); + + staleFetch.resolve([catalogEntry]); + await fetchPromise; + expect(useBuildSystemsCatalogStore.getState().entries).toEqual([]); + }); +}); + +describe("workspace activation", () => { + test("returning A to B to A realigns native commands before edits and sync", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + nextResult = cargoView(WORKSPACE_A, 1); + const cleanupRuntime = await startBuildSystemsRuntime(); + await flushMicrotasks(); + + nextResult = cargoView(WORKSPACE_B, 2); + activateWorkspace("ws-b", WORKSPACE_B); + await flushMicrotasks(); + nextResult = cargoView(WORKSPACE_A, 3); + activateWorkspace("ws-a", WORKSPACE_A); + await flushMicrotasks(); + + const refreshRoots = invokeCalls + .filter((call) => call.command === "declarative_plugins_build_refresh") + .map((call) => call.args.workspaceRoot); + expect(refreshRoots).toEqual([WORKSPACE_A, WORKSPACE_B, WORKSPACE_A]); + expect(useBuildSystemsStore.getStore("ws-a").getState().status).toBe("ready"); + + nextResult = cargoView(WORKSPACE_A, 4); + await setBuildOption("dev.lithe.fixture.build-static-file", "cargo", ".", "release", true); + const edit = [...invokeCalls] + .reverse() + .find((call) => call.command === "declarative_plugins_build_set_option"); + expect(edit?.args.workspaceRoot).toBe(WORKSPACE_A); + + let scheduledCheck: (() => void) | null = null; + const releaseWatcher = await subscribeBuildSyncWatcher((callback) => { + scheduledCheck = callback; + return () => { + scheduledCheck = null; + }; + }); + cleanups.push(releaseWatcher); + nextResult = cargoView(WORKSPACE_A, 5); + fileChangeHandler!({ payload: { path: "Cargo.toml", event_type: "reloaded" } }); + scheduledCheck!(); + await flushMicrotasks(); + const sync = [...invokeCalls] + .reverse() + .find((call) => call.command === "declarative_plugins_build_sync_check"); + expect(sync?.args.workspaceRoot).toBe(WORKSPACE_A); + + cleanupRuntime(); + }); +}); + +describe("explicit command model operations", () => { + const review = { + operationId: "fixture-operation", + pluginId: catalogEntry.pluginId, + buildSystemId: "cargo", + workspaceRoot: WORKSPACE_A, + root: ".", + generation: 1, + executesProjectCode: true, + mayUseNetwork: true, + }; + + test("caller disposal before prepare completes cancels its late native slot without approval", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const controller = new AbortController(); + cleanups.push(() => controller.abort()); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const approve = mock(async () => true); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + approve, + {}, + { signal: controller.signal }, + ); + await flushMicrotasks(); + controller.abort(); + prepared.resolve(review); + expect(await pending).toBeNull(); + expect(approve).toHaveBeenCalledTimes(0); + expect( + invokeCalls.filter((row) => row.command === "declarative_plugins_build_cancel_operation"), + ).toHaveLength(1); + expect(invokeCalls.some((row) => row.command === "declarative_plugins_build_load_model")).toBe( + false, + ); + }); + + for (const invalidation of ["generation", "selection", "option", "disable"] as const) { + test(`${invalidation} invalidation aborts consent without relying on the bootstrap listener`, async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const actions = useBuildSystemsStore.getState().actions; + const initial = cargoView(WORKSPACE_A, 1); + initial.instances.push({ ...initial.instances[0]!, root: "nested" }); + actions.applyView(initial, actions.beginRequest()); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const consent = deferred(); + let signal: AbortSignal | undefined; + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + (_review, input) => { + signal = input; + return consent.promise as Promise; + }, + ); + await flushMicrotasks(); + prepared.resolve(review); + await flushMicrotasks(); + if (invalidation === "generation") + actions.applyView({ ...initial, generation: 2 }, actions.beginRequest()); + else if (invalidation === "selection") + actions.selectInstance(`build-system:${catalogEntry.pluginId}:cargo`, "nested"); + else if (invalidation === "option") + actions.applyView( + { + ...initial, + instances: initial.instances.map((row) => ({ ...row, optionFingerprint: "changed" })), + }, + actions.beginRequest(), + ); + else actions.dropPlugin(catalogEntry.pluginId); + expect(signal!.aborted).toBe(true); + consent.resolve(true); + expect(await pending).toBeNull(); + expect( + invokeCalls.some((row) => row.command === "declarative_plugins_build_load_model"), + ).toBe(false); + }); + } + + test("an option request invalidates open consent before its deferred response lands", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const actions = useBuildSystemsStore.getState().actions; + const initial = cargoView(WORKSPACE_A, 1); + actions.applyView(initial, actions.beginRequest()); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const changed = deferCommand("declarative_plugins_build_set_option"); + const consent = deferred(); + let signal: AbortSignal | undefined; + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + (_review, input) => { + signal = input; + return consent.promise as Promise; + }, + ); + await flushMicrotasks(); + prepared.resolve(review); + await flushMicrotasks(); + const edit = setBuildOption(catalogEntry.pluginId, "cargo", ".", "release", true); + // Invalidate at request initiation, while native has returned no new + // fingerprint/generation that could otherwise close this old review. + expect(signal!.aborted).toBe(true); + consent.resolve(true); + expect(await pending).toBeNull(); + expect(invokeCalls.some((row) => row.command === "declarative_plugins_build_load_model")).toBe( + false, + ); + changed.resolve({ + ...initial, + instances: initial.instances.map((row) => ({ ...row, optionFingerprint: "changed" })), + }); + await edit; + expect( + useBuildSystemsStore.getState().windows[`build-system:${catalogEntry.pluginId}:cargo`]! + .instances[0]!.optionFingerprint, + ).toBe("changed"); + }); + + test("a review with the wrong identity is cancelled without asking for consent", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const approve = mock(async () => true); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + approve, + ); + const failed = pending.catch((error) => error); + await flushMicrotasks(); + prepared.resolve({ ...review, root: "foreign" }); + expect(String(await failed)).toContain("staleContext"); + expect(approve).toHaveBeenCalledTimes(0); + expect( + invokeCalls.some((row) => row.command === "declarative_plugins_build_cancel_operation"), + ).toBe(true); + }); + + test("approval exceptions release the review and report the failure", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + async () => { + throw new Error("fixture consent failure"); + }, + ); + const failure = pending.catch((error) => error); + await flushMicrotasks(); + prepared.resolve(review); + expect(String(await failure)).toContain("fixture consent failure"); + expect(invokeCalls.some((row) => row.command === "declarative_plugins_build_load_model")).toBe( + false, + ); + expect( + invokeCalls.filter((row) => row.command === "declarative_plugins_build_cancel_operation"), + ).toHaveLength(1); + }); + + test("a result with the wrong operation cannot mark the model loaded", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const loaded = deferCommand("declarative_plugins_build_load_model"); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + async () => true, + ); + const failure = pending.catch((error) => error); + await flushMicrotasks(); + prepared.resolve(review); + await flushMicrotasks(); + loaded.resolve({ + operationId: "wrong-operation", + view: cargoView(WORKSPACE_A, 1), + stderr: "late", + }); + expect(String(await failure)).toContain("staleContext"); + expect(useBuildSystemsStore.getState().workspaceRoot).toBeNull(); + }); + + test("cancellation failure is surfaced, retry remains possible, and a late load cannot apply", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const loaded = deferCommand("declarative_plugins_build_load_model"); + const cancelled = deferCommand("declarative_plugins_build_cancel_operation"); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + async () => true, + ); + const outcome = pending.catch((error) => error); + await flushMicrotasks(); + prepared.resolve(review); + await flushMicrotasks(); + const stop = buildSystemsService.cancelBuildModel(review.operationId).catch((error) => error); + await flushMicrotasks(); + cancelled.reject(new Error("fixture cancel failure")); + expect(String(await stop)).toContain("fixture cancel failure"); + loaded.resolve({ + operationId: review.operationId, + view: cargoView(WORKSPACE_A, 1), + stderr: "late success", + }); + expect(await outcome).toBeInstanceOf(buildSystemsService.BuildModelCancellationError); + expect(useBuildSystemsStore.getState().workspaceRoot).toBeNull(); + const retried = deferCommand("declarative_plugins_build_cancel_operation"); + const retry = buildSystemsService.cancelBuildModel(review.operationId); + await flushMicrotasks(); + retried.resolve(undefined); + await retry; + expect( + invokeCalls.filter((row) => row.command === "declarative_plugins_build_cancel_operation"), + ).toHaveLength(2); + }); + + test("Stop after model initialization cleanup failure retries the retained shared language owner", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const shared = deferred(); + const cleanup = deferred(); + const retryCleanup = mock(async () => { + await cleanup.promise; + }); + sharedLanguageStart.mockImplementation(async () => { + await shared.promise; + }); + const providerReview: BuildModelReview = { + ...review, + provider: { + token: 7, + context: { + window: "test-window", + workspaceRoot: WORKSPACE_A, + generation: 1, + pluginId: review.pluginId, + packageIdentity: "fixture:digest", + buildSystem: "cargo", + instanceRoot: ".", + instanceDirectory: WORKSPACE_A, + }, + executesProjectCode: true, + mayUseNetwork: true, + launchPlan: { buildProvider: { languageServer: "fixture-language" } }, + program: { + pluginId: review.pluginId, + languageId: "fixture-language", + serverPath: "fixture.exe", + serverArgs: [], + environment: {}, + }, + }, + }; + const native = { + prepare: mock(async () => providerReview), + load: mock(async () => ({ + operationId: review.operationId, + view: cargoView(WORKSPACE_A, 1), + stderr: "", + })), + cancel: mock(async () => {}), + }; + const outcome = buildSystemsService + .loadBuildModel(review.pluginId, "cargo", ".", async () => true, {}, { native }) + .catch((error: unknown) => error); + try { + await flushMicrotasks(); + expect(sharedLanguageStart).toHaveBeenCalledTimes(1); + const stopped = buildSystemsService + .cancelBuildModel(review.operationId) + .catch((error: unknown) => error); + shared.reject(Object.assign(new Error("plugin.build.cleanupFailed"), { retryCleanup })); + expect(String(await stopped)).toContain("plugin.build.cleanupFailed"); + expect(await outcome).toBeInstanceOf(buildSystemsService.BuildModelCancellationError); + expect(native.load).not.toHaveBeenCalled(); + let finished = false; + const retry = buildSystemsService.cancelBuildModel(review.operationId).then(() => { + finished = true; + }); + await flushMicrotasks(); + expect(native.cancel).toHaveBeenCalledTimes(2); + expect(retryCleanup).toHaveBeenCalledTimes(1); + expect(finished).toBe(false); + cleanup.resolve(undefined); + await retry; + expect(finished).toBe(true); + } finally { + shared.resolve(undefined); + cleanup.resolve(undefined); + await outcome; + await buildSystemsService.cancelBuildModel(review.operationId); + } + }); + + test("cancel acknowledgement is awaited and concurrent callers share the owned cleanup", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const loaded = deferCommand("declarative_plugins_build_load_model"); + const cleanup = deferCommand("declarative_plugins_build_cancel_operation"); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + async () => true, + ); + await flushMicrotasks(); + prepared.resolve(review); + await flushMicrotasks(); + let acknowledged = false; + const stop = buildSystemsService.cancelBuildModel(review.operationId).then(() => { + acknowledged = true; + }); + const second = buildSystemsService.cancelBuildModel(review.operationId); + await flushMicrotasks(); + expect(acknowledged).toBe(false); + expect( + invokeCalls.filter((row) => row.command === "declarative_plugins_build_cancel_operation"), + ).toHaveLength(1); + cleanup.resolve(undefined); + await Promise.all([stop, second]); + expect(acknowledged).toBe(true); + loaded.resolve({ + operationId: review.operationId, + view: cargoView(WORKSPACE_A, 1), + stderr: "late", + }); + expect(await pending).toBeNull(); + }); + + test("Stop joins cleanup already pending after a declined review", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const cleanup = deferCommand("declarative_plugins_build_cancel_operation"); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + async () => false, + ); + await flushMicrotasks(); + prepared.resolve(review); + await flushMicrotasks(); + const stop = buildSystemsService.cancelBuildModel(review.operationId); + await flushMicrotasks(); + expect( + invokeCalls.filter((row) => row.command === "declarative_plugins_build_cancel_operation"), + ).toHaveLength(1); + cleanup.resolve(undefined); + await stop; + expect(await pending).toBeNull(); + }); + + test("only confirmation starts the prepared operation and applies its matching result", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const loaded = deferCommand("declarative_plugins_build_load_model"); + const approve = mock(async () => true); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + approve, + { java: "C:/fixture tools/jdk/bin/java.exe" }, + ); + await flushMicrotasks(); + expect( + invokeCalls.some((call) => call.command === "declarative_plugins_build_load_model"), + ).toBe(false); + prepared.resolve(review); + await flushMicrotasks(); + expect(approve).toHaveBeenCalledTimes(1); + const execute = invokeCalls.find( + (call) => call.command === "declarative_plugins_build_load_model", + ); + expect(execute?.args).toEqual({ operationId: review.operationId }); + loaded.resolve({ + operationId: review.operationId, + view: cargoView(WORKSPACE_A, 1), + stderr: "fixture warning", + }); + expect((await pending)?.stderr).toBe("fixture warning"); + expect(useBuildSystemsStore.getStore("ws-a").getState().status).toBe("ready"); + }); + + test("declining the review releases the native slot without execution", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + async () => false, + ); + await flushMicrotasks(); + prepared.resolve(review); + expect(await pending).toBeNull(); + expect( + invokeCalls.some((call) => call.command === "declarative_plugins_build_load_model"), + ).toBe(false); + expect( + invokeCalls.some((call) => call.command === "declarative_plugins_build_cancel_operation"), + ).toBe(true); + }); + + test("switching to the welcome screen aborts consent and cancels immediately", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + nextResult = cargoView(WORKSPACE_A, 1); + const cleanup = await startBuildSystemsRuntime(); + cleanups.push(cleanup); + await flushMicrotasks(); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const consent = deferred(); + let signal: AbortSignal | null = null; + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + (_review, nextSignal) => { + signal = nextSignal; + return consent.promise as Promise; + }, + ); + await flushMicrotasks(); + prepared.resolve(review); + await flushMicrotasks(); + activateWorkspace("welcome", null); + await flushMicrotasks(); + expect(signal!.aborted).toBe(true); + expect(await pending).toBeNull(); + expect( + invokeCalls.some((call) => call.command === "declarative_plugins_build_load_model"), + ).toBe(false); + consent.resolve(true); + }); + + test("workspace refresh can cancel a running load and a late model cannot update either workspace", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + nextResult = cargoView(WORKSPACE_A, 1); + const cleanup = await startBuildSystemsRuntime(); + cleanups.push(cleanup); + await flushMicrotasks(); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const loaded = deferCommand("declarative_plugins_build_load_model"); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + async () => true, + ); + await flushMicrotasks(); + prepared.resolve(review); + await flushMicrotasks(); + nextResult = cargoView(WORKSPACE_B, 2); + activateWorkspace("ws-b", WORKSPACE_B); + await flushMicrotasks(); + expect( + invokeCalls.some((call) => call.command === "declarative_plugins_build_cancel_operation"), + ).toBe(true); + expect( + invokeCalls.some( + (call) => + call.command === "declarative_plugins_build_refresh" && + call.args.workspaceRoot === WORKSPACE_B, + ), + ).toBe(true); + loaded.resolve({ + operationId: review.operationId, + view: cargoView(WORKSPACE_A, 99), + stderr: "late", + }); + expect(await pending).toBeNull(); + expect(useBuildSystemsStore.getStore("ws-a").getState().generation).toBe(1); + expect(useBuildSystemsStore.getStore("ws-b").getState().generation).toBe(2); + }); + + test("revocation during consent cannot be approved after re-enable", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + nextResult = cargoView(WORKSPACE_A, 1); + const cleanup = await startBuildSystemsRuntime(); + cleanups.push(cleanup); + await flushMicrotasks(); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const consent = deferred(); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + () => consent.promise as Promise, + ); + await flushMicrotasks(); + prepared.resolve(review); + await flushMicrotasks(); + lifecycleHandler!({ payload: { pluginId: catalogEntry.pluginId, active: false } }); + await flushMicrotasks(); + consent.resolve(true); + expect(await pending).toBeNull(); + expect( + invokeCalls.some((call) => call.command === "declarative_plugins_build_load_model"), + ).toBe(false); + expect(useBuildSystemsStore.getStore("ws-a").getState().windows).toEqual({}); + }); + + test("execution failure stays failed after native slot cleanup", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const loaded = deferCommand("declarative_plugins_build_load_model"); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + async () => true, + ); + const failed = pending.then( + () => null, + (error: unknown) => error, + ); + await flushMicrotasks(); + prepared.resolve(review); + await flushMicrotasks(); + loaded.reject(new Error("plugin.build.outputLimit")); + expect(String(await failed)).toContain("plugin.build.outputLimit"); + expect(useBuildSystemsStore.getStore("ws-a").getState().status).toBe("failed"); + expect( + invokeCalls.some((call) => call.command === "declarative_plugins_build_cancel_operation"), + ).toBe(true); + }); +}); diff --git a/windows/tauri/src/features/build-systems/build-systems-service.ts b/windows/tauri/src/features/build-systems/build-systems-service.ts new file mode 100644 index 0000000000..3bbd0e76f6 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-systems-service.ts @@ -0,0 +1,697 @@ +import { create, type StoreApi } from "zustand"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { invoke } from "@/platform/tauri-core"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import type { + BuildCatalogEntry, + BuildView, + BuildModelReview, + BuildModelLoadResult, +} from "./build-systems-types"; +import { validBuildProviderReview } from "./build-systems-types"; +import { + beginBuildProviderLanguage, + type BuildProviderLanguageInitialization, +} from "./build-provider-language"; +import { + BUILD_SYSTEMS_STORE_KEY, + buildSystemViewId, + useBuildSystemsStore, + type BuildSystemsState, +} from "./build-systems-store"; + +/** + * Host bridge for the declarative build registry. The Rust side + * (`plugin_host/build.rs`) owns detection, option semantics, sync digests, + * and the trust gate; this service only forwards calls and guards late + * responses. Every request captures its initiating workspace's store and a + * sequence number, so a response that lands after a workspace switch, an + * out-of-order refresh, or a plugin disable can never write another + * workspace's store or revive a revoked window. + */ + +interface FileChangeEvent { + path: string; + event_type: "opened" | "reloaded" | "deleted" | "rescan"; +} + +interface PluginLifecyclePayload { + pluginId: string; + active: boolean; + operationId?: string; +} + +/** Milliseconds of file-change quiet before one sync check runs. */ +export const BUILD_SYNC_DEBOUNCE_MS = 500; + +/** Catalog of declared build systems, for activity-rail registration. */ +export const useBuildSystemsCatalogStore = create<{ entries: BuildCatalogEntry[] }>(() => ({ + entries: [], +})); + +// Catalog responses are sequenced like build views: a fetch that started +// before a plugin disable must not overwrite the post-disable catalog. +let catalogSequence = 0; +let lastCatalogSequence = 0; + +// The host stores one current workspace per product window. Dispatch registry +// operations in initiation order so a delayed B refresh cannot run after the +// refresh that reactivates A and silently move the native session back to B. +let registryTail: Promise = Promise.resolve(); +let activeWorkspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(); +let activationEpoch = 0; + +interface PendingModelLoad { + workspaceId: string; + pluginId: string; + operationId: string | null; + controller: AbortController; + cancellation: Promise | null; + cancelError: unknown; + sharedInitialization?: BuildProviderLanguageInitialization; + cancelNative: (operationId: string) => Promise; +} +const pendingModelLoads = new Set(); + +function cancelModelLoads(predicate: (pending: PendingModelLoad) => boolean): void { + for (const pending of pendingModelLoads) { + if (!predicate(pending)) continue; + // The owning load awaits the same cleanup in finally and surfaces errors. + void requestModelCancellation(pending).catch(() => {}); + } +} + +/** Cleanup failure keeps the operation available to the UI's Stop retry. */ +export class BuildModelCancellationError extends Error {} + +function requestModelCancellation(pending: PendingModelLoad): Promise { + pending.controller.abort(); + if (!pending.operationId) return Promise.resolve(); + if (pending.cancellation) return pending.cancellation; + pending.cancelError = null; + const cancellation = Promise.resolve().then(async () => { + await pending.cancelNative(pending.operationId!); + await pending.sharedInitialization?.cleanup(); + pendingModelLoads.delete(pending); + }); + pending.cancellation = cancellation; + void cancellation.catch((error: unknown) => { + pending.cancelError = error; + pending.cancellation = null; + }); + return cancellation; +} + +async function finishModelLoad(pending: PendingModelLoad, completed: boolean): Promise { + try { + if (!completed && pending.operationId) { + if (pending.cancelError) throw pending.cancelError; + await requestModelCancellation(pending); + } + } catch (error) { + // Cleanup failure takes priority: the UI must retain a retryable Stop + // until native confirms that the operation has ended. + throw new BuildModelCancellationError(failureMessage(error)); + } + // A failed cleanup keeps both the native port and shared-language retry + // reachable. Concurrent Stop calls join it until cleanup really succeeds. + pendingModelLoads.delete(pending); +} + +/** Approval owns its dialog until resolved or aborted by a workspace/lifecycle change. */ +export type ApproveBuildModel = (review: BuildModelReview, signal: AbortSignal) => Promise; +export interface BuildModelNativePort { + prepare(arguments_: { + workspaceRoot: string; + pluginId: string; + buildSystemId: string; + root: string; + runtimeExecutables: Record; + }): Promise; + load(operationId: string): Promise; + cancel(operationId: string): Promise; +} +const buildModelNative: BuildModelNativePort = { + prepare: (arguments_) => invoke("declarative_plugins_build_prepare_model", arguments_), + load: (operationId) => invoke("declarative_plugins_build_load_model", { operationId }), + cancel: (operationId) => invoke("declarative_plugins_build_cancel_operation", { operationId }), +}; + +async function awaitModelApproval( + review: BuildModelReview, + approve: ApproveBuildModel, + signal: AbortSignal, +): Promise { + if (signal.aborted) return false; + let onAbort: () => void = () => {}; + const aborted = new Promise((resolve) => { + onAbort = () => resolve(false); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + return await Promise.race([approve(review, signal), aborted]); + } finally { + signal.removeEventListener("abort", onAbort); + } +} + +/** Explicit action only. Passive detection never calls this operation. */ +export async function loadBuildModel( + pluginId: string, + buildSystemId: string, + root: string, + approve: ApproveBuildModel, + runtimeExecutables: Record = {}, + options: { + signal?: AbortSignal; + onPrepared?: (review: BuildModelReview) => void; + native?: BuildModelNativePort; + } = {}, +): Promise { + const { store, workspaceId, workspaceRoot, isCurrent } = initiatingStore(); + if (!workspaceRoot) return null; + const native = options.native ?? buildModelNative; + const pending: PendingModelLoad = { + workspaceId, + pluginId, + operationId: null, + controller: new AbortController(), + cancellation: null, + cancelError: null, + cancelNative: native.cancel, + }; + pendingModelLoads.add(pending); + let request: ReturnType | null = null; + let completed = false; + const initial = store.getState(); + let expectedRequestSequence = initial.requestSequence; + const generation = initial.generation; + const revocationEpoch = initial.pluginRevocationEpochs[pluginId] ?? 0; + const windowKey = buildSystemViewId(pluginId, buildSystemId); + const originalWindow = initial.windows[windowKey]; + const fingerprint = originalWindow?.instances.find( + (instance) => instance.root === root, + )?.optionFingerprint; + const stillCurrent = () => + !pending.controller.signal.aborted && + isCurrent() && + workspaceRuntimeRegistry.getWorkspace(workspaceId)?.stores.get(BUILD_SYSTEMS_STORE_KEY) === + store && + !store.getState().revokedPlugins[pluginId] && + (store.getState().pluginRevocationEpochs[pluginId] ?? 0) === revocationEpoch && + store.getState().generation === generation && + store.getState().requestSequence === expectedRequestSequence && + (!originalWindow || + (store.getState().windows[windowKey]?.selectedRoot === originalWindow.selectedRoot && + store.getState().windows[windowKey]?.instances.find((instance) => instance.root === root) + ?.optionFingerprint === fingerprint)) && + (!request || store.getState().lastAppliedSequence <= request.sequence); + const abort = () => { + void requestModelCancellation(pending).catch(() => {}); + }; + const checkContext = () => { + if (!completed && !stillCurrent()) abort(); + }; + const releaseStore = store.subscribe(checkContext); + const releaseRegistry = workspaceRuntimeRegistry.subscribe(checkContext); + options.signal?.addEventListener("abort", abort, { once: true }); + if (options.signal?.aborted) abort(); + try { + const review = await enqueueRegistryOperation(() => + stillCurrent() + ? native.prepare({ + workspaceRoot, + pluginId, + buildSystemId, + root, + runtimeExecutables: { ...runtimeExecutables }, + }) + : Promise.resolve(null), + ); + if (!review) return null; + pending.operationId = review.operationId; + options.onPrepared?.(review); + if ( + review.pluginId !== pluginId || + review.buildSystemId !== buildSystemId || + review.root !== root || + review.workspaceRoot !== workspaceRoot || + (generation !== null && review.generation !== generation) || + !validBuildProviderReview(review) + ) { + throw new Error("plugin.build.staleContext"); + } + if ( + !stillCurrent() || + !(await awaitModelApproval(review, approve, pending.controller.signal)) || + !stillCurrent() + ) + return null; + // Only this operation's own request may advance the captured sequence. + pending.sharedInitialization = beginBuildProviderLanguage(review, pending.controller.signal); + await pending.sharedInitialization.ready; + if (!stillCurrent()) return null; + // Competing edits/refreshes invalidate consent before their response lands. + expectedRequestSequence += 1; + request = store.getState().actions.beginRequest(); + if (!stillCurrent()) return null; + // Execution must not occupy registryTail: refresh/revoke/close must be + // able to reach native cancellation while a model process is running. + const result = await native.load(review.operationId); + if (!stillCurrent()) return null; + if ( + result.operationId !== review.operationId || + result.view.workspaceRoot !== workspaceRoot || + result.view.generation !== review.generation + ) + throw new Error("plugin.build.staleContext"); + completed = true; + store.getState().actions.applyView(result.view, request); + return result; + } catch (error) { + if (stillCurrent()) { + if (request) store.getState().actions.fail(failureMessage(error), request); + throw error; + } + return null; + } finally { + releaseStore(); + releaseRegistry(); + options.signal?.removeEventListener("abort", abort); + pending.controller.abort(); + if (request) store.getState().actions.cancelRequest(request); + await finishModelLoad(pending, completed); + } +} + +export async function cancelBuildModel(operationId: string): Promise { + for (const pending of pendingModelLoads) { + if (pending.operationId === operationId) return requestModelCancellation(pending); + } + // The existing native contract waits for cleanup, not just acceptance. + await invoke("declarative_plugins_build_cancel_operation", { operationId }); +} + +function captureActivationEpoch(): number { + const nextId = workspaceRuntimeRegistry.getActiveWorkspaceId(); + if (nextId !== activeWorkspaceId) { + activeWorkspaceId = nextId; + activationEpoch += 1; + } + return activationEpoch; +} + +export function enqueueRegistryOperation(operation: () => Promise): Promise { + const result = registryTail.then(operation); + registryTail = result.catch(() => {}); + return result; +} + +function applyCatalog(entries: BuildCatalogEntry[], sequence: number): void { + if (sequence < lastCatalogSequence) { + return; + } + lastCatalogSequence = sequence; + useBuildSystemsCatalogStore.setState({ entries }); +} + +export async function fetchBuildCatalog(): Promise { + const sequence = ++catalogSequence; + const entries = await invoke("declarative_plugins_build_catalog"); + applyCatalog(entries, sequence); + return entries; +} + +/** Current workspace root for this window, or null on the welcome screen. */ +export function currentWorkspaceRoot(): string | null { + return workspaceRuntimeRegistry.getActiveWorkspace()?.descriptor.path ?? null; +} + +/** + * The store of the workspace a request is initiated from. Captured before + * the await, so a completion never writes whichever workspace happens to be + * active by then; a closed workspace's detached store simply absorbs + * nothing the user can see. + */ +function initiatingStore(): { + workspaceId: string; + store: StoreApi; + workspaceRoot: string | null; + isCurrent: () => boolean; +} { + const workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(); + const epoch = captureActivationEpoch(); + return { + workspaceId, + store: useBuildSystemsStore.getStore(workspaceId), + workspaceRoot: currentWorkspaceRoot(), + isCurrent: () => + epoch === captureActivationEpoch() && + workspaceId === workspaceRuntimeRegistry.getActiveWorkspaceId(), + }; +} + +function failureMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Detects build-system instances for the current workspace and applies the + * view to the initiating workspace's store. Safe to call concurrently. + */ +export async function refreshBuildSystems(): Promise { + const { workspaceId, store, workspaceRoot, isCurrent } = initiatingStore(); + cancelModelLoads((pending) => pending.workspaceId === workspaceId); + if (!workspaceRoot) { + store.getState().actions.reset(); + return fetchBuildCatalog(); + } + const sequence = store.getState().actions.beginRequest(); + const [catalog] = await Promise.all([ + fetchBuildCatalog(), + enqueueRegistryOperation(() => + isCurrent() + ? invoke("declarative_plugins_build_refresh", { workspaceRoot }) + : Promise.resolve(null), + ) + .then((view) => { + if (view && isCurrent()) store.getState().actions.applyView(view, sequence); + }) + .catch((error: unknown) => { + if (isCurrent()) store.getState().actions.fail(failureMessage(error), sequence); + }), + ]); + // A workspace removed while the request ran leaves its store detached; + // only report the catalog for callers that are still current. + return workspaceId === workspaceRuntimeRegistry.getActiveWorkspaceId() ? catalog : []; +} + +export async function setBuildOption( + pluginId: string, + buildSystemId: string, + root: string, + optionId: string, + value: unknown, +): Promise { + const { store, workspaceId, workspaceRoot, isCurrent } = initiatingStore(); + if (!workspaceRoot) { + return; + } + const sequence = store.getState().actions.beginRequest(); + const view = await enqueueRegistryOperation(() => + isCurrent() + ? invoke("declarative_plugins_build_set_option", { + workspaceRoot, + pluginId, + buildSystemId, + root, + optionId, + value: value ?? null, + }) + : Promise.resolve(null), + ).catch((error: unknown) => { + if (isCurrent()) store.getState().actions.fail(failureMessage(error), sequence); + throw error; + }); + if (view && isCurrent()) store.getState().actions.applyView(view, sequence); + if (view && isCurrent()) { + const { updateBuildLanguageImport } = await import("./build-language-import"); + await updateBuildLanguageImport({ workspaceId, root: workspaceRoot }); + } +} + +export async function syncInstance( + pluginId: string, + buildSystemId: string, + root: string, + options: { observeOnly?: boolean } = {}, +): Promise { + const { workspaceId, store, workspaceRoot, isCurrent } = initiatingStore(); + if (!workspaceRoot) { + return; + } + const initial = store.getState(); + const sequence = options.observeOnly + ? { sequence: initial.requestSequence, revocationEpoch: initial.revocationEpoch } + : initial.actions.beginRequest(); + const windowId = buildSystemViewId(pluginId, buildSystemId); + const observationCurrent = () => + isCurrent() && + workspaceRuntimeRegistry.getWorkspace(workspaceId)?.stores.get(BUILD_SYSTEMS_STORE_KEY) === + store && + store.getState().requestSequence === sequence.sequence && + store.getState().revocationEpoch === sequence.revocationEpoch; + const view = await enqueueRegistryOperation(() => + (options.observeOnly ? observationCurrent() : isCurrent()) + ? invoke("declarative_plugins_build_sync_check", { + workspaceRoot, + pluginId, + buildSystemId, + root, + }) + : Promise.resolve(null), + ).catch((error: unknown) => { + if (options.observeOnly) { + if (observationCurrent()) { + const invalidation = store.getState().actions.beginRequest(); + store.getState().actions.fail(failureMessage(error), invalidation); + } + } else if (isCurrent()) store.getState().actions.fail(failureMessage(error), sequence); + throw error; + }); + if (!view || !isCurrent()) return; + if (!options.observeOnly) { + store.getState().actions.applyView(view, sequence); + return; + } + if (!observationCurrent()) return; + const current = store.getState(); + const original = current.windows[windowId]?.instances.find((row) => row.root === root); + const updated = view.instances.find( + (row) => row.pluginID === pluginId && row.buildSystemID === buildSystemId && row.root === root, + ); + // Own saves and generated output also notify the file watcher. Checking a + // fingerprint is passive; only an actual native context change invalidates + // consent, so unchanged notifications must not cancel their own build. + if ( + view.workspaceRoot === workspaceRoot && + view.generation === current.generation && + updated?.syncDigest === original?.syncDigest && + updated?.stale === original?.stale && + updated?.optionFingerprint === original?.optionFingerprint && + JSON.stringify(updated?.modelError) === JSON.stringify(original?.modelError) && + !!updated === !!original + ) + return; + const invalidation = store.getState().actions.beginRequest(); + store.getState().actions.applyView(view, invalidation); +} + +/** + * Watches workspace file changes and re-checks every instance's sync + * fingerprint after a debounce, mirroring the macOS rule that saves within + * the window collapse into one check. Returns the unlisten function; the + * tool window owns this subscription and cleans it up on unmount. + */ +export async function subscribeBuildSyncWatcher( + schedule: (callback: () => void, delayMs: number) => () => void = (callback, delayMs) => { + const handle = setTimeout(callback, delayMs); + return () => clearTimeout(handle); + }, +): Promise { + let pending: (() => void) | null = null; + let timer: (() => void) | null = null; + const runChecks = () => { + pending = null; + if (!currentWorkspaceRoot()) { + return; + } + // Read the active workspace's store at fire time; a workspace switch + // during the debounce checks the new workspace's instances only. + const state = useBuildSystemsStore.getState(); + if (!state.workspaceRoot || state.generation === null) { + return; + } + for (const window of Object.values(state.windows)) { + for (const instance of window.instances) { + syncInstance(instance.pluginID, instance.buildSystemID, instance.root, { + observeOnly: true, + }).catch(() => { + // A failed sync (workspace mid-switch, plugin disabled) keeps the + // last state; the next change or manual refresh retries. + }); + } + } + }; + const unlisten = await listen("ln", (event) => { + if (!currentWorkspaceRoot()) { + return; + } + pending = runChecks; + timer?.(); + timer = schedule(() => { + timer = null; + pending?.(); + }, BUILD_SYNC_DEBOUNCE_MS); + }); + return () => { + timer?.(); + pending = null; + unlisten(); + }; +} + +/** Refcounted per-mount sync watcher shared by every build tool window. */ +let watcherConsumers = 0; +let watcherUnlisten: UnlistenFn | null = null; +let watcherAcquisition: Promise | null = null; + +export async function acquireBuildSyncWatcher(): Promise<() => void> { + watcherConsumers += 1; + if (!watcherUnlisten && !watcherAcquisition) { + watcherAcquisition = subscribeBuildSyncWatcher().then((unlisten) => { + watcherUnlisten = unlisten; + watcherAcquisition = null; + }); + } + await watcherAcquisition; + return () => { + watcherConsumers -= 1; + if (watcherConsumers === 0 && watcherUnlisten) { + watcherUnlisten(); + watcherUnlisten = null; + } + }; +} + +/** + * Revokes one plugin everywhere: its catalog entries, every instantiated + * workspace store's window state, and the native registry sessions. The + * frontend lifecycle listener calls this when a plugin is disabled, + * replaced, or uninstalled. + */ +async function revokePlugin(pluginId: string): Promise { + cancelModelLoads((pending) => pending.pluginId === pluginId); + const sequence = ++catalogSequence; + applyCatalog( + useBuildSystemsCatalogStore.getState().entries.filter((entry) => entry.pluginId !== pluginId), + sequence, + ); + for (const store of workspaceRuntimeRegistry.getExistingStores( + BUILD_SYSTEMS_STORE_KEY, + )) { + store.getState().actions.dropPlugin(pluginId); + } + await enqueueRegistryOperation(() => + invoke("declarative_plugins_build_plugins_changed", { pluginId }), + ).catch(() => { + // The native revoke is best-effort from the listener; every later + // command re-checks the package against the trust gate anyway. + }); +} + +function allowPluginEverywhere(pluginId: string): void { + for (const store of workspaceRuntimeRegistry.getExistingStores( + BUILD_SYSTEMS_STORE_KEY, + )) { + store.getState().actions.allowPlugin(pluginId); + } +} + +/** Refcounted plugin-lifecycle listener shared by the bootstrap runtime. */ +let lifecycleConsumers = 0; +let lifecycleUnlisten: UnlistenFn | null = null; +let lifecycleAcquisition: Promise | null = null; + +async function acquireLifecycleListener(): Promise<() => void> { + lifecycleConsumers += 1; + if (!lifecycleUnlisten && !lifecycleAcquisition) { + lifecycleAcquisition = listen( + "declarative-plugin-lifecycle", + ({ payload }) => { + if (payload.active) { + allowPluginEverywhere(payload.pluginId); + void refreshBuildSystems().catch(() => {}); + return; + } + void revokePlugin(payload.pluginId); + }, + // Same targeting rule as the other declarative listeners: only this + // product window drains its own lifecycle events. + { target: { kind: "Window", label: getCurrentWindow().label } }, + ).then((unlisten) => { + lifecycleUnlisten = unlisten; + lifecycleAcquisition = null; + }); + } + await lifecycleAcquisition; + return () => { + lifecycleConsumers -= 1; + if (lifecycleConsumers === 0 && lifecycleUnlisten) { + lifecycleUnlisten(); + lifecycleUnlisten = null; + } + }; +} + +/** Cleanup registered by the bootstrap runtime; see startBuildSystemsRuntime. */ +export let cleanupBuildSystemsRuntime: (() => void) | null = null; + +/** + * Keeps the window-owned native registry aligned with the active workspace. + * Stores retain their ready views across tab switches, but the Rust registry + * intentionally has only one current workspace per window, so every actual + * activation must re-detect once even when the returning store is ready. + */ +function subscribeWorkspaceActivations(): () => void { + let observedWorkspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(); + return workspaceRuntimeRegistry.subscribe(() => { + const nextWorkspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(); + if (nextWorkspaceId === observedWorkspaceId) { + return; + } + cancelModelLoads((pending) => pending.workspaceId === observedWorkspaceId); + observedWorkspaceId = nextWorkspaceId; + captureActivationEpoch(); + void refreshBuildSystems().catch(() => {}); + }); +} + +/** + * Bootstrap entry: registers the plugin-lifecycle listener (catalog and + * registry revocation) and warms the catalog. Returns the cleanup; the page + * also cleans up on hide, so a destroyed window never keeps the listener. + */ +export async function startBuildSystemsRuntime(): Promise<() => void> { + const releaseLifecycle = await acquireLifecycleListener(); + const releaseWorkspaceActivations = subscribeWorkspaceActivations(); + // `window` is absent under the bun test runner; the cleanup stays + // available for callers there. + const hasWindow = typeof window !== "undefined"; + let cleaned = false; + const cleanup = () => { + if (cleaned) { + return; + } + cleaned = true; + cancelModelLoads(() => true); + if (hasWindow) { + window.removeEventListener("pagehide", onPageHide); + } + releaseWorkspaceActivations(); + releaseLifecycle(); + if (cleanupBuildSystemsRuntime === cleanup) { + cleanupBuildSystemsRuntime = null; + } + }; + const onPageHide = () => cleanup(); + if (hasWindow) { + window.addEventListener("pagehide", onPageHide, { once: true }); + } + cleanupBuildSystemsRuntime = cleanup; + // Align the native window session on startup as well as warming the + // catalog. The welcome workspace path still skips native detection. + void refreshBuildSystems().catch(() => {}); + return cleanup; +} diff --git a/windows/tauri/src/features/build-systems/build-systems-store.test.ts b/windows/tauri/src/features/build-systems/build-systems-store.test.ts new file mode 100644 index 0000000000..8235961398 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-systems-store.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, test } from "bun:test"; +import { + buildSystemViewId, + parseBuildSystemViewId, + useBuildSystemsStore, +} from "./build-systems-store"; +import type { BuildView } from "./build-systems-types"; + +/** + * Store tests for the build tool-window display state: grouping, selection + * preservation, request-sequence guards, plugin revocation, and instance + * selection validation. Pure state transitions with no timers and no host + * calls. + */ + +function view(generation: number, roots: string[], selectedIssue = false): BuildView { + return { + generation, + workspaceRoot: "C:/work/demo", + instances: roots.map((root) => ({ + pluginID: "dev.lithe.fixture.build-static-file", + buildSystemID: "cargo", + displayName: "Cargo", + root, + markers: ["Cargo.toml"], + modelSource: "static", + model: { projects: [{ name: "fixture-app", path: root }] }, + stale: false, + syncDigest: null, + optionFingerprint: "fp", + options: [], + })), + issues: selectedIssue ? [{ kind: "shadowed", pluginID: "p2", by: "p1" }] : [], + }; +} + +const VIEW_ID = buildSystemViewId("dev.lithe.fixture.build-static-file", "cargo"); +const request = (sequence: number, revocationEpoch = 0) => ({ + sequence, + revocationEpoch, +}); + +describe("build systems store", () => { + test("groups instances of one build system into a single window", () => { + useBuildSystemsStore.getState().actions.reset(); + useBuildSystemsStore.getState().actions.applyView(view(1, ["a", "b"]), request(1)); + + const state = useBuildSystemsStore.getState(); + expect(Object.keys(state.windows)).toEqual([VIEW_ID]); + expect(state.windows[VIEW_ID]!.instances.map((instance) => instance.root)).toEqual(["a", "b"]); + expect(state.windows[VIEW_ID]!.selectedRoot).toBe("a"); + expect(state.status).toBe("ready"); + }); + + test("the instance selection survives refreshes and falls back when removed", () => { + useBuildSystemsStore.getState().actions.reset(); + useBuildSystemsStore.getState().actions.applyView(view(1, ["a", "b"]), request(1)); + useBuildSystemsStore.getState().actions.selectInstance(VIEW_ID, "b"); + expect(useBuildSystemsStore.getState().windows[VIEW_ID]!.selectedRoot).toBe("b"); + + useBuildSystemsStore.getState().actions.applyView(view(2, ["a", "b"]), request(2)); + expect(useBuildSystemsStore.getState().windows[VIEW_ID]!.selectedRoot).toBe("b"); + + useBuildSystemsStore.getState().actions.applyView(view(3, ["b"]), request(3)); + expect(useBuildSystemsStore.getState().windows[VIEW_ID]!.selectedRoot).toBe("b"); + + useBuildSystemsStore.getState().actions.applyView(view(4, ["c"]), request(4)); + expect(useBuildSystemsStore.getState().windows[VIEW_ID]!.selectedRoot).toBe("c"); + }); + + test("an unknown instance root is not selectable", () => { + useBuildSystemsStore.getState().actions.reset(); + useBuildSystemsStore.getState().actions.applyView(view(1, ["a"]), request(1)); + + useBuildSystemsStore.getState().actions.selectInstance(VIEW_ID, "not-a-root"); + expect(useBuildSystemsStore.getState().windows[VIEW_ID]!.selectedRoot).toBe("a"); + }); + + test("a workspace change discards the previous view", () => { + useBuildSystemsStore.getState().actions.reset(); + useBuildSystemsStore.getState().actions.applyView(view(2, ["a"]), request(2)); + + const switched = { ...view(1, ["b"]), workspaceRoot: "C:/work/other" }; + useBuildSystemsStore.getState().actions.applyView(switched, request(3)); + + const state = useBuildSystemsStore.getState(); + expect(state.generation).toBe(2); + expect(state.windows[VIEW_ID]!.instances[0]!.root).toBe("a"); + }); + + test("out-of-order responses are dropped by request sequence", () => { + useBuildSystemsStore.getState().actions.reset(); + // Sequence 2 applies first. + useBuildSystemsStore.getState().actions.applyView(view(5, ["new"]), request(2)); + // Sequence 1 is a late response and must not overwrite it. + useBuildSystemsStore.getState().actions.applyView(view(4, ["old"]), request(1)); + + const state = useBuildSystemsStore.getState(); + expect(state.generation).toBe(5); + expect(state.windows[VIEW_ID]!.instances[0]!.root).toBe("new"); + }); + + test("a late failure never clobbers a newer applied view", () => { + useBuildSystemsStore.getState().actions.reset(); + const { beginRequest, applyView, fail } = useBuildSystemsStore.getState().actions; + const first = beginRequest(); + applyView(view(3, ["a"]), first); + const second = beginRequest(); + applyView(view(4, ["b"]), second); + fail("late boom", first); + + const state = useBuildSystemsStore.getState(); + expect(state.status).toBe("ready"); + expect(state.error).toBeNull(); + expect(state.windows[VIEW_ID]!.instances[0]!.root).toBe("b"); + }); + + test("dropPlugin removes that plugin's windows and issues only", () => { + useBuildSystemsStore.getState().actions.reset(); + const otherInstance = { + pluginID: "dev.lithe.other", + buildSystemID: "gradle", + displayName: "Gradle", + root: "a", + markers: [], + modelSource: "static" as const, + model: null, + stale: false, + syncDigest: null, + optionFingerprint: null, + options: [], + }; + const mixed = view(1, ["a"], true); + useBuildSystemsStore.getState().actions.applyView( + { + ...mixed, + instances: [...mixed.instances, otherInstance], + issues: [ + ...mixed.issues, + { + kind: "rejected" as const, + pluginID: "dev.lithe.other", + error: { code: "x", message: "y" }, + }, + ], + }, + request(1), + ); + + useBuildSystemsStore.getState().actions.dropPlugin("dev.lithe.fixture.build-static-file"); + + const state = useBuildSystemsStore.getState(); + expect(Object.keys(state.windows)).toEqual([buildSystemViewId("dev.lithe.other", "gradle")]); + expect( + state.issues.every((issue) => issue.pluginID !== "dev.lithe.fixture.build-static-file"), + ).toBe(true); + }); + + test("issues ride along with the view", () => { + useBuildSystemsStore.getState().actions.reset(); + useBuildSystemsStore.getState().actions.applyView(view(1, ["a"], true), request(1)); + expect(useBuildSystemsStore.getState().issues).toEqual([ + { kind: "shadowed", pluginID: "p2", by: "p1" }, + ]); + }); + + test("a response crossing plugin revocation cannot revive that plugin", () => { + useBuildSystemsStore.getState().actions.reset(); + const started = useBuildSystemsStore.getState().actions.beginRequest(); + + useBuildSystemsStore.getState().actions.dropPlugin("dev.lithe.fixture.build-static-file"); + useBuildSystemsStore.getState().actions.applyView(view(1, ["late"]), started); + + const state = useBuildSystemsStore.getState(); + expect(state.windows[VIEW_ID]).toBeUndefined(); + expect(state.status).toBe("ready"); + }); + + test("revocation filters only its plugin from a late complete view", () => { + useBuildSystemsStore.getState().actions.reset(); + const started = useBuildSystemsStore.getState().actions.beginRequest(); + const other = { + ...view(1, ["revoked"]).instances[0]!, + pluginID: "dev.lithe.other", + buildSystemID: "gradle", + displayName: "Gradle", + root: "kept", + }; + + useBuildSystemsStore.getState().actions.dropPlugin("dev.lithe.fixture.build-static-file"); + useBuildSystemsStore + .getState() + .actions.applyView( + { ...view(1, ["revoked"]), instances: [...view(1, ["revoked"]).instances, other] }, + started, + ); + + const state = useBuildSystemsStore.getState(); + expect(state.windows[VIEW_ID]).toBeUndefined(); + expect(state.windows[buildSystemViewId("dev.lithe.other", "gradle")]?.instances[0]?.root).toBe( + "kept", + ); + }); + + test("a failure crossing revocation cannot replace the remaining plugin state", () => { + useBuildSystemsStore.getState().actions.reset(); + const current = useBuildSystemsStore.getState().actions.beginRequest(); + useBuildSystemsStore.getState().actions.applyView(view(1, ["a"]), current); + const late = useBuildSystemsStore.getState().actions.beginRequest(); + + useBuildSystemsStore.getState().actions.dropPlugin("dev.lithe.unrelated-disabled-plugin"); + useBuildSystemsStore.getState().actions.fail("late failure", late); + + const state = useBuildSystemsStore.getState(); + expect(state.status).toBe("ready"); + expect(state.error).toBeNull(); + expect(state.windows[VIEW_ID]).toBeDefined(); + }); + + test("view ids round-trip through the parser", () => { + expect(parseBuildSystemViewId(VIEW_ID)).toEqual({ + pluginId: "dev.lithe.fixture.build-static-file", + buildSystemId: "cargo", + }); + expect(parseBuildSystemViewId("maven")).toBeNull(); + expect(parseBuildSystemViewId("build-system:only-one-part")).toBeNull(); + expect(parseBuildSystemViewId("build-system:a:")).toBeNull(); + expect(parseBuildSystemViewId("build-system::b")).toBeNull(); + }); +}); diff --git a/windows/tauri/src/features/build-systems/build-systems-store.ts b/windows/tauri/src/features/build-systems/build-systems-store.ts new file mode 100644 index 0000000000..76ca1defda --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-systems-store.ts @@ -0,0 +1,253 @@ +import { create } from "zustand"; +import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; +import type { BuildIssue, BuildView, BuildWindowState } from "./build-systems-types"; + +export const BUILD_SYSTEMS_STORE_KEY = "build-systems"; + +export interface BuildRequestVersion { + sequence: number; + /** Store-wide revocation clock captured before the native call starts. */ + revocationEpoch: number; +} + +/** + * Display state for the declarative build tool windows, scoped to one + * workspace. The Rust registry owns the build facts; this store only + * mirrors the latest view and the user's instance selection. + */ + +export interface BuildSystemsState { + status: "idle" | "loading" | "ready" | "failed"; + generation: number | null; + workspaceRoot: string | null; + error: string | null; + issues: BuildIssue[]; + windows: Record; + /** + * Monotonic sequence of requests this workspace's store issued. Responses + * carry the sequence they were initiated with; anything older than the + * last applied sequence is a late or out-of-order response and is dropped, + * whether it reports success or failure. + */ + requestSequence: number; + lastAppliedSequence: number; + /** Monotonic clock used to identify requests that predate a plugin revoke. */ + revocationEpoch: number; + /** Last revoke clock per plugin, retained across a later re-enable. */ + pluginRevocationEpochs: Record; + /** Plugins that remain revoked until an active lifecycle event arrives. */ + revokedPlugins: Record; + actions: { + /** Marks a request in flight and returns its sequence and revoke clock. */ + beginRequest: () => BuildRequestVersion; + applyView: (view: BuildView, request: BuildRequestVersion) => void; + fail: (message: string, request: BuildRequestVersion) => void; + cancelRequest: (request: BuildRequestVersion) => void; + selectInstance: (viewId: string, root: string) => void; + /** Drops one disabled plugin and invalidates requests started before it. */ + dropPlugin: (pluginId: string) => void; + /** Allows post-enable requests to return the plugin again. */ + allowPlugin: (pluginId: string) => void; + reset: () => void; + }; +} + +/** Tool-window view id of one plugin build system. */ +export function buildSystemViewId(pluginId: string, buildSystemId: string): string { + return `build-system:${pluginId}:${buildSystemId}`; +} + +export function parseBuildSystemViewId( + view: string, +): { pluginId: string; buildSystemId: string } | null { + if (!view.startsWith("build-system:")) { + return null; + } + const rest = view.slice("build-system:".length); + const separator = rest.indexOf(":"); + if (separator <= 0 || separator === rest.length - 1) { + return null; + } + return { + pluginId: rest.slice(0, separator), + buildSystemId: rest.slice(separator + 1), + }; +} + +function applyViewToState( + state: BuildSystemsState, + view: BuildView, + request: BuildRequestVersion, +): Partial { + // Late responses never win: an out-of-order sequence, a changed workspace + // root, or an older generation all mean the store already moved on. + if (request.sequence < state.lastAppliedSequence) { + return {}; + } + if (state.workspaceRoot && view.workspaceRoot !== state.workspaceRoot) { + return {}; + } + if (state.generation !== null && view.generation < state.generation) { + return {}; + } + + const wasRevokedAfterRequest = (pluginId: string | undefined) => + !!pluginId && + (state.revokedPlugins[pluginId] === true || + (state.pluginRevocationEpochs[pluginId] ?? 0) > request.revocationEpoch); + const grouped = new Map(); + for (const instance of view.instances) { + // A request that started before disable may still carry that plugin in + // its native snapshot. Drop only the revoked owner so unrelated plugin + // state in the same complete view remains legal and can still update. + if (wasRevokedAfterRequest(instance.pluginID)) { + continue; + } + const viewId = buildSystemViewId(instance.pluginID, instance.buildSystemID); + const existing = grouped.get(viewId); + if (existing) { + existing.instances.push(instance); + continue; + } + grouped.set(viewId, { + pluginId: instance.pluginID, + buildSystemId: instance.buildSystemID, + displayName: instance.displayName, + instances: [instance], + selectedRoot: null, + }); + } + // Preserve the user's instance selection when the instance is still there; + // otherwise fall back to the first instance deterministically. + const windows: Record = {}; + for (const [viewId, next] of grouped) { + const previous = state.windows[viewId]; + const stillSelected = + previous?.selectedRoot && + next.instances.some((instance) => instance.root === previous.selectedRoot); + next.selectedRoot = stillSelected ? previous!.selectedRoot : (next.instances[0]?.root ?? null); + windows[viewId] = next; + } + + return { + status: "ready", + generation: view.generation, + workspaceRoot: view.workspaceRoot, + error: null, + issues: view.issues.filter( + (issue) => !wasRevokedAfterRequest(issue.pluginID) && !wasRevokedAfterRequest(issue.by), + ), + windows, + lastAppliedSequence: request.sequence, + }; +} + +const createBuildSystemsStore = () => + create()((set, get) => ({ + status: "idle", + generation: null, + workspaceRoot: null, + error: null, + issues: [], + windows: {}, + requestSequence: 0, + lastAppliedSequence: 0, + revocationEpoch: 0, + pluginRevocationEpochs: {}, + revokedPlugins: {}, + actions: { + beginRequest: () => { + const sequence = get().requestSequence + 1; + set((state) => ({ requestSequence: sequence, status: "loading" })); + return { sequence, revocationEpoch: get().revocationEpoch }; + }, + applyView: (view, request) => set((state) => applyViewToState(state, view, request)), + fail: (message, request) => + set((state) => { + // A failure only lands when no newer response already applied; + // otherwise the late error would clobber fresher state. A request + // crossing any revoke cannot name a safe owner, so its failure is + // stale as well. + if ( + request.sequence < state.lastAppliedSequence || + request.revocationEpoch !== state.revocationEpoch + ) { + return state; + } + return { + status: "failed", + error: message, + lastAppliedSequence: request.sequence, + }; + }), + cancelRequest: (request) => + set((state) => + state.status === "loading" && + request.sequence === state.requestSequence && + request.revocationEpoch === state.revocationEpoch + ? { status: state.workspaceRoot ? "ready" : "idle" } + : state, + ), + dropPlugin: (pluginId) => + set((state) => { + const revocationEpoch = state.revocationEpoch + 1; + const windows = Object.fromEntries( + Object.entries(state.windows).filter(([, target]) => target.pluginId !== pluginId), + ); + const issues = state.issues.filter((issue) => issue.pluginID !== pluginId); + return { + status: "ready", + error: null, + windows, + issues, + revocationEpoch, + pluginRevocationEpochs: { + ...state.pluginRevocationEpochs, + [pluginId]: revocationEpoch, + }, + revokedPlugins: { ...state.revokedPlugins, [pluginId]: true }, + }; + }), + allowPlugin: (pluginId) => + set((state) => { + if (!state.revokedPlugins[pluginId]) { + return state; + } + const revokedPlugins = { ...state.revokedPlugins }; + delete revokedPlugins[pluginId]; + return { revokedPlugins }; + }), + selectInstance: (viewId, root) => + set((state) => { + const target = state.windows[viewId]; + if (!target || !target.instances.some((instance) => instance.root === root)) { + return state; + } + return { + windows: { + ...state.windows, + [viewId]: { ...target, selectedRoot: root }, + }, + }; + }), + reset: () => + set({ + status: "idle", + generation: null, + workspaceRoot: null, + error: null, + issues: [], + windows: {}, + requestSequence: 0, + lastAppliedSequence: 0, + revocationEpoch: 0, + pluginRevocationEpochs: {}, + revokedPlugins: {}, + }), + }, + })); + +export const useBuildSystemsStore = createWorkspaceScopedStore( + BUILD_SYSTEMS_STORE_KEY, + createBuildSystemsStore, +); diff --git a/windows/tauri/src/features/build-systems/build-systems-types.ts b/windows/tauri/src/features/build-systems/build-systems-types.ts new file mode 100644 index 0000000000..89bea6654d --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-systems-types.ts @@ -0,0 +1,197 @@ +/** + * Shapes returned by the Rust build registry (`plugin_host/build.rs`). The + * frontend only renders this state; every build semantic is computed by Core + * through the registry, so no ecosystem knowledge lives here. + */ + +export interface BuildOptionChoice { + value: string; + title?: string; + fromModel?: boolean; +} + +export interface BuildOptionDescription { + id: string; + displayName?: string; + kind: "boolean" | "singleSelect" | "multiSelect" | "text" | "file" | "directory"; + value: unknown; + choices: BuildOptionChoice[]; + allowCustom?: boolean; +} + +export interface BuildModelTarget { + id?: string; + projectID?: string; + path?: string; + name: string; + tags?: string[]; +} + +export interface BuildModelTask { + id: string; + name: string; + group?: string; + category?: string[]; + projectID?: string; + template?: string; + execution?: "task" | "application" | "service"; + displayName?: string; +} + +export interface BuildModelProject { + id?: string; + displayName?: string; + name: string; + path: string; + properties?: Record; + targets?: BuildModelTarget[]; + tasks?: BuildModelTask[]; +} + +export interface BuildModelError { + code: string; + message: string; +} + +/** One detected build-system instance with its session state. */ +export interface BuildInstanceView { + pluginID: string; + buildSystemID: string; + displayName: string; + root: string; + markers: string[]; + modelSource: "static" | "command" | "hostIntegration" | "provider" | null; + model?: { + projects?: BuildModelProject[]; + targets?: BuildModelTarget[]; + tasks?: BuildModelTask[]; + truncated?: boolean; + } | null; + modelError?: BuildModelError | null; + stale: boolean; + syncDigest: string | null; + optionFingerprint: string | null; + options: BuildOptionDescription[]; +} + +export interface BuildIssue { + kind: "rejected" | "shadowed"; + pluginID?: string; + buildSystemID?: string; + by?: string; + index?: number; + error?: { code: string; message: string }; +} + +export interface BuildView { + generation: number; + workspaceRoot: string; + instances: BuildInstanceView[]; + issues: BuildIssue[]; +} + +/** A single-use native review, bound to the calling window and generation. */ +export interface BuildModelReview { + operationId: string; + pluginId: string; + buildSystemId: string; + workspaceRoot: string; + root: string; + generation: number; + executesProjectCode: boolean; + mayUseNetwork: boolean; + provider?: BuildProviderReview; +} + +export interface BuildProviderReview { + token: number; + context: { + window: string; + workspaceRoot: string; + launchWorkspaceRoot?: string; + generation: number; + pluginId: string; + packageIdentity: string; + buildSystem: string; + instanceRoot: string; + instanceDirectory: string; + }; + executesProjectCode: boolean; + mayUseNetwork: boolean; + launchPlan: Record; + invocationPlan?: Record; + program: { + pluginId: string; + languageId: string; + serverPath: string; + serverArgs: string[]; + environment: Record; + initializationOptions?: unknown; + workspaceConfiguration?: unknown; + }; +} + +/** Refuse incomplete or foreign native reviews before asking for consent. */ +function normalizedProviderWorkspace(value: string): string { + return value + .replace(/\\/g, "/") + .replace(/^\/\/\?\/UNC\//i, "//") + .replace(/^\/\/\?\//, "") + .replace(/\/$/, "") + .toLowerCase(); +} +export function validBuildProviderReview(review: BuildModelReview): boolean { + const provider = review.provider; + if (provider === undefined) return true; + return ( + !!provider && + Number.isSafeInteger(provider.token) && + provider.token > 0 && + provider.context?.pluginId === review.pluginId && + provider.context.buildSystem === review.buildSystemId && + typeof provider.context.workspaceRoot === "string" && + normalizedProviderWorkspace(provider.context.workspaceRoot) === + normalizedProviderWorkspace(review.workspaceRoot) && + provider.context.generation === review.generation && + provider.context.instanceRoot === review.root && + typeof provider.context.packageIdentity === "string" && + provider.context.packageIdentity.length > 0 && + typeof provider.program?.serverPath === "string" && + provider.program.serverPath.length > 0 && + provider.program.pluginId === review.pluginId && + Array.isArray(provider.program.serverArgs) && + provider.program.serverArgs.every((arg) => typeof arg === "string") && + !!provider.launchPlan && + typeof provider.launchPlan === "object" && + !!provider.program.environment && + typeof provider.program.environment === "object" && + typeof provider.executesProjectCode === "boolean" && + typeof provider.mayUseNetwork === "boolean" && + (!provider.executesProjectCode || review.executesProjectCode) && + (!provider.mayUseNetwork || review.mayUseNetwork) + ); +} + +export interface BuildModelLoadResult { + operationId: string; + view: BuildView; + stderr: string; +} + +/** Declared build system of one enabled plugin, for window registration. */ +export interface BuildCatalogEntry { + pluginId: string; + pluginDisplayName: string; + buildSystemId: string; + buildSystemDisplayName: string; + icon: string | null; +} + +/** Instances of one build system, as one tool window shows them. */ +export interface BuildWindowState { + pluginId: string; + buildSystemId: string; + displayName: string; + instances: BuildInstanceView[]; + selectedRoot: string | null; +} diff --git a/windows/tauri/src/features/build-systems/components/build-dependencies.test.tsx b/windows/tauri/src/features/build-systems/components/build-dependencies.test.tsx new file mode 100644 index 0000000000..e44ca56376 --- /dev/null +++ b/windows/tauri/src/features/build-systems/components/build-dependencies.test.tsx @@ -0,0 +1,210 @@ +import { afterAll, afterEach, expect, mock, spyOn, test } from "bun:test"; +import { act } from "react"; +import * as events from "@tauri-apps/api/event"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { buildSystemViewId, useBuildSystemsStore } from "../build-systems-store"; +import type { BuildDependenciesNative } from "../build-dependencies-native"; +import type { BuildDependenciesResult, BuildDependenciesReview } from "../build-dependencies-types"; +import type { BuildOutputEvent } from "../output/build-output-types"; +import { useBuildOutputStore } from "../output/build-output-store"; +import type { BuildView } from "../build-systems-types"; +const restoreDom = installHappyDom(); +const { createRoot } = await import("react-dom/client"); +const { default: BuildSystemPane } = await import("./build-system-pane"); +const { DependencyRows } = await import("./build-dependencies"); +afterAll(restoreDom); +const cleanups: Array<() => void | Promise> = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); + workspaceRuntimeRegistry.resetForTests(); +}); +const ROOT = "D:/fixture/dependencies-ui"; +const PLUGIN = "dev.lithe.fixture.dependencies-ui"; +function view(): BuildView { + return { + generation: 4, + workspaceRoot: ROOT, + issues: [], + instances: [ + { + pluginID: PLUGIN, + buildSystemID: "fixture", + displayName: "Fixture", + root: ".", + markers: [], + modelSource: "hostIntegration", + model: { projects: [{ id: "project", name: "App", path: "app" }] }, + options: [], + stale: false, + syncDigest: "sync", + optionFingerprint: "options", + }, + ], + }; +} +async function mount(provider = false) { + workspaceRuntimeRegistry.activateWorkspace( + { id: "dependencies-ui", name: "Fixture", path: ROOT }, + "ready", + ); + const store = useBuildSystemsStore.getStore("dependencies-ui"); + store.getState().actions.reset(); + const initial = view(); + store.getState().actions.applyView(initial, store.getState().actions.beginRequest()); + useBuildOutputStore.getStore("dependencies-ui").getState().actions.reset(); + const review: BuildDependenciesReview = { + operationId: "dependencies-ui-1", + pluginId: PLUGIN, + buildSystemId: "fixture", + workspaceRoot: ROOT, + root: ".", + generation: 4, + executesProjectCode: true, + mayUseNetwork: true, + projectId: null, + title: "Fixture", + kind: "dependencies", + }; + let sink: ((event: BuildOutputEvent) => void) | null = null; + const native: BuildDependenciesNative = { + windowLabel: () => "workspace-fixture", + save: mock(async () => {}), + sync: mock(async () => { + const actions = store.getState().actions; + actions.applyView(initial, actions.beginRequest()); + }), + definition: mock(async () => ({ + tasks: [], + programs: [ + { + kind: "buildSystem", + buildSystemID: "fixture", + invocation: { executables: [{ kind: "command", command: "fixture-tool" }] }, + }, + ], + dependencies: { + source: provider ? ("provider" as const) : ("hostIntegration" as const), + scopes: ["workspace" as const], + }, + })), + prepare: mock(async () => review), + execute: mock(async (): Promise => { + const terminal = { kind: "succeeded" as const, exitCode: 0 }; + sink?.({ ...review, type: "terminal", terminal }); + return { + ...review, + terminal, + dependencies: [ + { + group: "fixture", + name: "library", + version: "2", + scope: "compile", + properties: { resolution: "omittedForConflict", selectedVersion: "3" }, + children: [{ name: "transitive", children: [] }], + }, + ], + }; + }), + cancel: mock(async () => {}), + listen: mock(async (_window, listener) => { + sink = listener; + return () => { + sink = null; + }; + }), + }; + const listen = spyOn(events, "listen").mockResolvedValue(() => {}); + cleanups.push(() => listen.mockRestore()); + const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; + const previous = environment.IS_REACT_ACT_ENVIRONMENT; + environment.IS_REACT_ACT_ENVIRONMENT = true; + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + cleanups.push(async () => { + await act(async () => root.unmount()); + host.remove(); + if (previous === undefined) delete environment.IS_REACT_ACT_ENVIRONMENT; + else environment.IS_REACT_ACT_ENVIRONMENT = previous; + }); + await act(async () => + root.render( + + {}} + dependenciesNative={native} + runNative={{ + ...native, + definition: async () => ({ tasks: [], programs: [] }), + prepare: async () => { + throw new Error("unexpected task"); + }, + execute: async () => ({ kind: "cancelled" }), + }} + /> + , + ), + ); + return { host, native, store }; +} +function button(container: ParentNode, text: string) { + const value = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === text, + ); + if (!value) throw new Error(`button missing: ${text}`); + return value; +} + +test("production pane mounts dependencies and requires review before rendering Core tree", async () => { + const f = await mount(); + expect(f.native.prepare).not.toHaveBeenCalled(); + await act(async () => button(f.host, "Load dependencies…").click()); + expect(f.native.execute).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("Review dependency loading"); + expect(document.body.textContent).toContain("fixture-tool"); + await act(async () => button(document.body, "Approve and load").click()); + expect(f.native.execute).toHaveBeenCalledTimes(1); + expect(f.host.textContent).toContain("fixture:library:2"); + expect(f.host.textContent).not.toContain("transitive"); + const details = f.host.querySelector("details")!; + await act(async () => { + details.open = true; + details.dispatchEvent(new Event("toggle")); + }); + expect(f.host.textContent).toContain("transitive"); + expect(f.host.textContent).toContain("omittedForConflict"); + expect(f.host.textContent).toContain("selectedVersion"); +}); +test("Provider dependencies expose the existing explicit approval flow", async () => { + const f = await mount(true); + expect(f.host.textContent).toContain("Load dependencies…"); + expect(f.native.prepare).not.toHaveBeenCalled(); +}); +test("wide dependency levels mount a bounded first page", async () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + cleanups.push(async () => { + await act(async () => root.unmount()); + host.remove(); + }); + await act(async () => + root.render( + + ({ + name: `artifact-${index}`, + children: [], + }))} + /> + , + ), + ); + expect(host.querySelectorAll("summary").length).toBe(200); + await act(async () => button(host, "Show more dependencies").click()); + expect(host.querySelectorAll("summary").length).toBe(201); +}); diff --git a/windows/tauri/src/features/build-systems/components/build-dependencies.tsx b/windows/tauri/src/features/build-systems/components/build-dependencies.tsx new file mode 100644 index 0000000000..c4bf8a6f73 --- /dev/null +++ b/windows/tauri/src/features/build-systems/components/build-dependencies.tsx @@ -0,0 +1,331 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { useTranslation } from "@/i18n/locale-provider"; +import { Button } from "@/ui/button"; +import Select from "@/ui/select"; +import AppDialog from "@/ui/dialog"; +import BuildProviderReviewDetails from "./build-provider-review"; +import { + buildDependenciesNative, + type BuildDependenciesNative, +} from "../build-dependencies-native"; +import { + getBuildDependenciesSession, + type BuildDependenciesSession, +} from "../build-dependencies-session"; +import type { BuildDependencyNode, BuildDependenciesDefinition } from "../build-dependencies-types"; +import type { BuildInstanceView } from "../build-systems-types"; +import BuildOutputPane from "../output/components/build-output-pane"; + +export default function BuildDependencies({ + instance, + disabled, + native = buildDependenciesNative, +}: { + instance: BuildInstanceView; + disabled: boolean; + native?: BuildDependenciesNative; +}) { + const { t } = useTranslation(); + const workspaceId = useActiveWorkspaceId(); + const [session, setSession] = useState(null); + const [error, setError] = useState(""); + const [revision, setRevision] = useState(0); + useEffect(() => { + setSession(null); + setError(""); + try { + const next = getBuildDependenciesSession( + workspaceId, + instance.pluginID, + instance.buildSystemID, + instance.root, + native, + ); + const release = next.attach(); + setSession(next); + return release; + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + } + }, [workspaceId, instance.pluginID, instance.buildSystemID, instance.root, native, revision]); + if (error) + return ( +
    +

    {t("buildSystems.dependencies.failed", { message: error })}

    + +
    + ); + return session && + session.workspaceId === workspaceId && + session.root === instance.root && + session.pluginId === instance.pluginID && + session.buildSystemId === instance.buildSystemID ? ( + + ) : null; +} + +function DependenciesContent({ + instance, + session, + disabled, +}: { + instance: BuildInstanceView; + session: BuildDependenciesSession; + disabled: boolean; +}) { + const { t } = useTranslation(); + const state = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot); + const [definition, setDefinition] = useState(null); + const [definitionError, setDefinitionError] = useState(""); + const [revision, setRevision] = useState(0); + const [selected, setSelected] = useState(""); + useEffect(() => { + let current = true; + setDefinition(null); + setDefinitionError(""); + void session.native.definition(instance.pluginID, instance.buildSystemID).then( + (value) => { + if (current) setDefinition(value); + }, + (error) => { + if (current) setDefinitionError(error instanceof Error ? error.message : String(error)); + }, + ); + return () => { + current = false; + }; + }, [session, instance.pluginID, instance.buildSystemID, revision]); + const source = definition?.dependencies; + const scopes = + source?.source === "hostIntegration" ? ["workspace", "project"] : (source?.scopes ?? []); + const projects = instance.model?.projects?.filter((project) => !!project.id) ?? []; + const project = projects.find((project) => project.id === selected); + const projectId = selected ? project?.id : null; + const busy = ["preparing", "review", "starting", "running", "stopping", "cancelFailed"].includes( + state.phase, + ); + const valid = + !!source && + (selected + ? !!projectId && scopes.includes("project") && !instance.stale + : scopes.includes("workspace")); + if (definition && !source) return null; + return ( +
    +

    {t("buildSystems.dependencies.title")}

    + {source && ( + <> + { + setAction(value); + setTargetId(""); + setModelTaskId(""); + }} + options={[ + { value: "", label: t("buildSystems.run.chooseAction") }, + ...tasks.map((row) => ({ + value: `task:${row.id}`, + label: [...(row.category ?? (row.group ? [row.group] : [])), row.displayName].join( + " / ", + ), + })), + ...(definition.test ? [{ value: "test", label: t("buildSystems.run.tests") }] : []), + ]} + /> + {!!scopes.length && ( + { + setProjectId(value); + setTargetId(""); + setModelTaskId(""); + }} + options={[ + { value: "", label: t("buildSystems.run.chooseProject") }, + ...(instance.model?.projects ?? []) + .filter((row) => row.id) + .map((row) => ({ + value: row.id!, + label: `${row.displayName ?? row.name} (${row.path})`, + })), + ]} + /> + )} + {effectiveScope === "target" && ( + ({ + value: row.id, + label: [...(row.category ?? []), row.displayName ?? row.name].join(" / "), + })), + ]} + /> + )} + {testing && definition.test?.filters?.includes("suite") && ( + setSuite(event.target.value)} + /> + )} + {testing && definition.test?.filters?.includes("case") && ( + setCase(event.target.value)} + /> + )} + {(task || testing) && ( +