From 2f239ada3b725dea343853526a09d782cca87f67 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 20 Oct 2026 09:54:57 +0800 Subject: [PATCH 1/2] feat(plugins): move Go and PHP Support to Lithe Store community plugins Go Support and PHP Support leave the official plugin set and continue as community plugins io.github.lithe-idea.go-support and io.github.lithe-idea.php-support in their own repositories, listed in Lithe Store from 0.1.0. - OfficialPluginCatalog keeps the retired dev.lithe IDs with their publisherPackage signature requirement and records the community replacement; publishedEntries hides them from official entry points. - The marketplace gives retired IDs no official source, listing or update, even from a stale cached official index; uninstall stays available. - Plugin Management shows a "moved to the community" notice naming the replacement ID and asks the user to uninstall first. - The official index and release config are empty; the release flow is kept and tested with a synthetic plugin. - Remove the Intelephense download/assembly script and its worktree resource; the community PHP plugin uses the user's own Intelephense because its licence forbids redistribution. - Retired manifests move to test fixtures; docs, contracts, feature matrix and Agent Notes updated. --- ...-php-support-plugin-ownership-and-plans.md | 10 +- ...09-30-lsp-plugin-build-and-distribution.md | 14 +- ...2026-10-12-declarative-language-plugins.md | 2 +- .../2026-10-20-go-php-community-plugins.md | 96 ++++++++++++++ .../feature/2026-10-12-plugin-marketplace.md | 1 + .../Official/PhpSupport/language-server.json | 12 -- Plugins/mac/README.md | 73 +++++----- Plugins/marketplace/index.json | 69 +--------- Plugins/marketplace/official-plugins.json | 39 +----- docs/ci-builds.md | 9 +- .../zh-Hans.lproj/Localizable.strings | 5 +- .../Features/PluginMarketplace.swift | 11 +- .../Views/App/PluginManagementView.swift | 21 ++- .../Views/Language/LSPControlCenterView.swift | 6 +- .../Catalog/BuiltInModuleCatalog.swift | 24 +++- .../DeclarativePluginTestSupport.swift | 22 ++- .../GoSupport/plugin.json | 0 .../PhpSupport/plugin.json | 0 .../Tests/LitheTests/PluginManagerTests.swift | 10 ++ .../PluginMarketplaceFeatureModelTests.swift | 44 +++++- .../src/tests/declarative_plugins.rs | 10 -- scripts/build-official-plugins.sh | 9 -- scripts/official-plugin-distribution.mjs | 2 +- scripts/official-plugin-releases.mjs | 19 ++- scripts/official-plugin-releases.test.mjs | 72 +++++++--- scripts/prepare-php-language-server.sh | 125 ------------------ scripts/test-reuse-worktree-resources.mjs | 4 +- scripts/verify-macos-package.sh | 6 +- scripts/verify-official-plugins.sh | 13 +- scripts/worktree-resources.json | 14 +- shared/contracts/application-boundary.md | 15 ++- .../features/go-support-plugin.json | 10 +- .../features/php-optional-plugin.json | 21 ++- .../features/php-run-test.json | 4 +- .../features/plugin-marketplace.json | 2 +- 35 files changed, 373 insertions(+), 421 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-20-go-php-community-plugins.md delete mode 100644 Plugins/mac/Official/PhpSupport/language-server.json rename {Plugins/mac/Official => macos/Tests/LitheTests/Fixtures/RetiredOfficialPlugins}/GoSupport/plugin.json (100%) rename {Plugins/mac/Official => macos/Tests/LitheTests/Fixtures/RetiredOfficialPlugins}/PhpSupport/plugin.json (100%) delete mode 100755 scripts/prepare-php-language-server.sh diff --git a/.agents/notes/implemented/architecture/2026-09-18-php-support-plugin-ownership-and-plans.md b/.agents/notes/implemented/architecture/2026-09-18-php-support-plugin-ownership-and-plans.md index ba07c3c260..39407e6b2a 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-php-support-plugin-ownership-and-plans.md +++ b/.agents/notes/implemented/architecture/2026-09-18-php-support-plugin-ownership-and-plans.md @@ -6,6 +6,8 @@ PHP 支持由用户选择安装和启用,主程序不携带 PHP 插件包、Node/Bun 或 Intelephense。Intelephense 是通过语言服务器协议(LSP)与编辑器通信的第三方 PHP 分析服务;PHP 解释器只负责运行程序和 PHPUnit 测试。插件禁用后必须停止自己启动的任务和进程,不能影响其他语言或删除用户自行安装的工具。 +macOS 的 PHP 插件现在是 Lithe Store 社区插件 `io.github.lithe-idea.php-support`,不再打包 Intelephense,由用户自行安装,见 [Go 与 PHP 改为 Lithe Store 社区插件](2026-10-20-go-php-community-plugins.md)。下文关于 macOS 官方包打包 Intelephense 和 publisher 签名的描述是迁移前的历史;按需安装、进程归属和 Windows 部分仍然有效。 + ## 问题 只把 PHP 标记为默认禁用并不能实现按需分发:原有打包脚本会自动把所有官方原生插件放进应用。Windows 原来的 Composer 和 PHPUnit 动作还直接交给普通终端执行,禁用 PHP 扩展无法阻止入口发现或停止相关任务。工具安装只结束 Bun 的直接子进程,再无限等待输出线程,也无法保证取消完成。 @@ -14,7 +16,7 @@ PHP 支持由用户选择安装和启用,主程序不携带 PHP 插件包、No ### 分发与安装 -- `scripts/official-plugin-distribution.mjs` 显式列出随主程序分发的官方插件。PHP 不在其中;`build-official-plugins.sh --plugin-id dev.lithe.plugin.php-support` 仍能独立构建插件包,用户通过已有插件管理的 Install 入口安装签名与宿主一致的包。 +- `scripts/official-plugin-distribution.mjs` 显式列出随主程序分发的官方插件。PHP 不在其中;macOS 用户从插件市场的 Lithe Store 社区源安装 PHP 插件。 - macOS 的 PHP 语言服务和执行模块均默认禁用、按需激活。安装后的包提供 `.php`、`.phtml` 和 `composer.json` 声明;没有包时不注册它的进程能力。共享的轻量语法识别不需要下载或启动外部进程。 - macOS 的 PHP 插件是声明式包(`plugin.json` + Intelephense,无原生代码,见 [声明式语言插件](2026-10-12-declarative-language-plugins.md)),携带由 `language-server.json` 固定版本和 SHA-256 的 Intelephense 包。插件管理页下载并校验插件包,运行时把 Intelephense 保存在同一个用户级插件版本目录中;重装、回滚和卸载都处理同一份目录。Node.js 仍是 Intelephense 的外部运行时,PHP、Composer 和项目 PHPUnit 仍由用户自行提供;本地目录导入只用于离线或故障恢复。从 Dock 或 Finder 打开的 Lithe 只继承 launchd 的精简 PATH,里面没有 Homebrew 或 nvm/volta/fnm 安装的 Node;因此宿主在启动语言服务器前用运行时工具发现找到 `node`,通过 `LITHE_NODE_PATH` 传给插件里的 launcher,用户已设置的 `LITHE_NODE_PATH` 优先。语言服务器会在打开文件时自动启动,因此这里绝不选用项目目录里的 `node`(如 `.lithe/bin/node`),只用全局安装的 Node,避免打开不可信项目就执行项目自带的程序。 - macOS 下载地址使用宿主 App 的 `CFBundleShortVersionString` 组成 Release tag 和 zip 文件名;`BuiltInPluginCatalog.hostVersion` 只用于插件 API 兼容性校验,不能用来定位发布资产。 @@ -72,19 +74,15 @@ PHP 支持由用户选择安装和启用,主程序不携带 PHP 插件包、No - `MacPluginPackageDownloaderTests`:验证 stable/preview 发行资产 URL 按 App 发布版本和架构确定性生成。 - `LanguageExtensionProcessLifecycleTests.packagedServersArePinnedToTheirOwnPackage`、`LanguageServerToolServiceTests.pinnedExecutableIsTheOnlyCandidate`:验证插件打包的 Intelephense 只从本插件已安装的版本目录启动,不按文件名在 PATH 或其他插件中查找。 - `PluginPackageStoreTests/reinstallCanReplaceTheActiveVersionOnlyAfterValidation`:验证重装不会绕过签名校验,并在校验完成后替换当前版本。 -- `prepare-php-language-server.sh`:按 JSON 清单下载、校验并组装 Intelephense 运行包;插件版本目录删除时一并删除 launcher 和缓存文件。 -- `.github/workflows/release-macos.yml` 和 `.github/workflows/release-preview-macos.yml`:每个架构都发布 PHP 插件 zip;`LITHE_PLUGIN_PACKAGE_PRIVATE_KEY` 缺失或不匹配时工作流失败,避免发布无法被客户端识别的包。 - `cargo test --manifest-path rust/lithe-core/Cargo.toml --lib declarative_plugins`:PHP 运行、PHPUnit 整套/单文件/单方法命令展开与路径保护。 - `LITHE_RUN_PHP_INTEGRATION=1 ./scripts/test-macos.sh --filter RealPhpIntegrationTests`:真实工具测试;先在 `shared/fixtures/phpunit-project` 执行 `composer install`,并提供 Node.js 与插件组装出的 Intelephense launcher。 - Windows 前端测试包含禁用时不扫描、Composer 数组、下载取消、在途启动后禁用及跨工作区进程隔离。Windows native 测试与实际应用启动必须在 Windows 环境执行;Linux 交叉编译不等于运行验收。 ## 适用范围 -- `Plugins/mac/Official/PhpSupport/` -- `Plugins/mac/Official/PhpSupport/language-server.json` +- `macos/Tests/LitheTests/Fixtures/RetiredOfficialPlugins/PhpSupport/` - `macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift` - `macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift` -- `scripts/prepare-php-language-server.sh` - `Plugins/win/Official/PhpSupport/` - `scripts/build-official-plugins.sh` - `scripts/package-app.sh` diff --git a/.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md b/.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md index 91ccfb7c8c..e9163c1cad 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md +++ b/.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md @@ -4,9 +4,11 @@ ## 先说结论 -以后新增或修改带语言服务器(Language Server,负责通过 LSP 提供补全、诊断和跳转能力的进程)的插件,都必须沿用 PHP Support 的构建方式:插件声明固定版本和校验值,构建阶段下载、校验并把语言服务器放入插件包,插件包完成签名后再分发。主程序不再为插件维护第二套语言服务器路径,也不在运行时改写已安装的 app bundle。 +以后新增或修改带语言服务器(Language Server,负责通过 LSP 提供补全、诊断和跳转能力的进程)的官方插件,都必须沿用这里的构建方式(最早由 PHP Support 验证):插件声明固定版本和校验值,构建阶段下载、校验并把语言服务器放入插件包,插件包完成签名后再分发。主程序不再为插件维护第二套语言服务器路径,也不在运行时改写已安装的 app bundle。 -插件拥有语言服务器的文件和启动入口,并在 `plugin.json` 的 `languageServers` 中声明;Lithe 负责插件包验证、插件启停、运行时发现、进程生命周期和现有 LSP 会话编排。插件包是声明式的,不含宿主加载的代码,见 [声明式语言插件](2026-10-12-declarative-language-plugins.md)。运行时资源写入用户级 Application Support、Caches 或临时目录,插件卸载、重装和回滚必须能够连同自己的语言服务器一起清理或替换。 +插件拥有语言服务器的文件和启动入口,并在 `plugin.json` 的 `languageServers` 中声明;Lithe 负责插件包验证、插件启停、运行时发现、进程生命周期和现有 LSP 会话编排。插件包是声明式的,不含宿主加载的代码,见 [声明式语言插件](2026-10-12-declarative-language-plugins.md)。 + +PHP Support 后来迁到 Lithe Store 社区仓库,并因 Intelephense 许可证禁止再分发而改为启动用户安装的 `intelephense`(`systemCommand`),见 [Go 与 PHP 改为 Lithe Store 社区插件](2026-10-20-go-php-community-plugins.md)。打包语言服务器前必须先确认上游许可证允许再分发;不允许时只能声明 `systemCommand`,由用户自行安装。运行时资源写入用户级 Application Support、Caches 或临时目录,插件卸载、重装和回滚必须能够连同自己的语言服务器一起清理或替换。 ## 问题 @@ -20,7 +22,7 @@ PHP Support 已经验证了独立插件包、固定 Intelephense 版本和插件 每个带 LSP 的插件在自己的源码目录提供 `language-server.json`。清单至少固定以下事实:服务器版本、HTTPS 下载地址、SHA-256、归档格式、归档根目录、启动脚本相对路径和许可证路径。清单只描述构建所需的上游输入,不保存机器路径、用户目录或运行时缓存路径。 -当前 PHP 插件的示例是 `Plugins/mac/Official/PhpSupport/language-server.json`。如果另一个语言服务器使用 zip、单文件可执行程序或不同的启动方式,应扩展构建脚本支持的格式,并保持“固定来源、固定校验、构建后签名”的原则;不能跳过校验直接把网络下载物复制进插件。 +迁移前 PHP 插件使用过 `language-server.json`;主仓库目前没有需要下载语言服务器的可选官方插件。如果新的语言服务器使用 zip、单文件可执行程序或不同的启动方式,应扩展构建脚本支持的格式,并保持“固定来源、固定校验、构建后签名”的原则;不能跳过校验直接把网络下载物复制进插件。 ### 2. 构建阶段完成下载、解压和组装 @@ -35,7 +37,7 @@ PHP Support 已经验证了独立插件包、固定 Intelephense 版本和插件 唯一的例外是自带运行时的 Java Support:它的包里有每个架构各一份的 JDK,因此与架构有关,并且只随应用发行、由应用的代码签名覆盖。它的组装步骤在插件自己的目录里,见 [Java 语言服务器是一个声明式插件](2026-10-13-java-language-server-as-declarative-plugin.md)。 -PHP 的具体下载和组装逻辑位于 `scripts/prepare-php-language-server.sh`。后续插件可以复用相同的构建阶段和校验结构,但不能把某个语言的路径、版本或下载地址硬编码到宿主应用中。 +下载和组装逻辑放在插件专属的准备脚本里(PHP 迁出前是 `prepare-php-language-server.sh`,已随迁移删除)。后续插件可以复用相同的构建阶段和校验结构,但不能把某个语言的路径、版本或下载地址硬编码到宿主应用中。 ### 3. 插件包是发布和安装单位 @@ -90,10 +92,8 @@ macOS 使用声明式插件包和 publisher Ed25519 包签名;Windows 使用 ## 适用范围 -- `Plugins/mac/Official/*/language-server.json` -- `Plugins/mac/Official/PhpSupport/` +- `Plugins/mac/Official/` - `scripts/build-official-plugins.sh` -- `scripts/prepare-php-language-server.sh` - `macos/Sources/Lithe/Platform/MacOS/Plugins/` - `macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift` - `macos/Sources/LitheLanguageIntelligenceModule/` diff --git a/.agents/notes/implemented/architecture/2026-10-12-declarative-language-plugins.md b/.agents/notes/implemented/architecture/2026-10-12-declarative-language-plugins.md index 9f2bd80799..2770fb44d6 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-declarative-language-plugins.md +++ b/.agents/notes/implemented/architecture/2026-10-12-declarative-language-plugins.md @@ -4,7 +4,7 @@ ## 先说结论 -官方语言插件(Go、PHP)不再是"在 Lithe 进程里加载的 Swift Bundle",而是声明式插件包:包里只有 `plugin.json` 和它声明的资源(例如打包好的 Intelephense)。Lithe 读取声明,由 Rust Core 校验并展开运行/测试命令,再由宿主自己启动语言服务器和运行进程。插件不在宿主进程内执行任何代码,所以插件包不再依赖宿主二进制,Lithe 每次发版也不用重新编译插件,之后可以把插件拆到独立仓库维护。 +官方语言插件(Go、PHP)不再是"在 Lithe 进程里加载的 Swift Bundle",而是声明式插件包:包里只有 `plugin.json` 和它声明的资源(例如打包好的 Intelephense)。Lithe 读取声明,由 Rust Core 校验并展开运行/测试命令,再由宿主自己启动语言服务器和运行进程。插件不在宿主进程内执行任何代码,所以插件包不再依赖宿主二进制,Lithe 每次发版也不用重新编译插件,之后可以把插件拆到独立仓库维护。Go 和 PHP 后来确实拆成了 Lithe Store 社区插件,见 [Go 与 PHP 改为 Lithe Store 社区插件](2026-10-20-go-php-community-plugins.md)。 对日常开发的直接影响:新增语言插件时写 `plugin.json`,不要写 Swift 入口类;声明格式和语义以 `shared/contracts/plugins/v1/` 为准,校验与命令模板展开只在 `rust/lithe-core/src/plugins/declarative.rs` 实现一次。 diff --git a/.agents/notes/implemented/architecture/2026-10-20-go-php-community-plugins.md b/.agents/notes/implemented/architecture/2026-10-20-go-php-community-plugins.md new file mode 100644 index 0000000000..3ad378c587 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-go-php-community-plugins.md @@ -0,0 +1,96 @@ +# Agent 笔记:Go 与 PHP 改为 Lithe Store 社区插件 + +状态:已实现 + +## 先说结论 + +macOS 的 Go Support 和 PHP Support 不再是 Lithe 官方插件。它们搬到各自的社区仓库(`Lithe-IDEA/lithe-plugin-go`、`Lithe-IDEA/lithe-plugin-php`),用新 ID `io.github.lithe-idea.go-support` 和 `io.github.lithe-idea.php-support` 发布,并列在 Lithe Store 社区源里,版本从 0.1.0 开始。主仓库不再构建或发布这两个包,官方索引目前是空的。 + +已经装了旧官方包的用户不会被悄悄切换。插件管理会提示“已迁移到社区插件”,引导用户先卸载旧包,再从插件市场安装社区版。旧 ID 仍然按原来的官方签名要求校验,所以没人能用旧 ID 冒充官方包。 + +社区版 PHP 插件不再打包 Intelephense(PHP 语言服务器)。Intelephense 的许可证禁止再分发,所以改成由用户自己安装(`npm install -g intelephense`),插件按命令名 `intelephense` 启动它。 + +## 问题 + +Go 和 PHP 已经是声明式插件:包里只有 `plugin.json`(PHP 另有 Intelephense),不含宿主要加载的代码,见 [声明式语言插件](2026-10-12-declarative-language-plugins.md)。它们和 Lithe 本体没有编译关系,但仍然放在主仓库里: + +- 每次改插件都要走主仓库的 PR、CI 和官方签名发布流程,和 Lithe 本体的发版节奏绑在一起。 +- 官方插件要用 Lithe 的 publisher 私钥签名,维护成本集中在主仓库维护者身上。 +- PHP 官方包把 Intelephense 打进包里再分发,而 Intelephense 许可证第 5(c) 条不允许再分发,这在法律上站不住。 + +## 决策 + +### 1. 插件进入独立社区仓库 + +两个插件各自一个仓库,结构与已有的 `lithe-plugin-lua` 相同:`/plugin.json`、`scripts/package.mjs`、按 `v<版本>` 标签发布 GitHub Release 的工作流。包名为 `-support-<版本>-darwin.zip`,Lithe Store 的 `lithe-plugin-index.json` 记录下载地址和 SHA-256。 + +社区 ID 使用 `io.github.lithe-idea.*` 命名空间,`vendor.signatureRequirement` 为 `none`。插件市场对社区插件按 SHA-256 和安装确认框管理信任,不需要 Lithe 私钥。 + +### 2. 旧官方 ID 保留在宿主目录里,但没有官方来源 + +`OfficialPluginCatalog`(宿主内置的官方插件目录,`BuiltInModuleCatalog.swift`)继续列出 `dev.lithe.plugin.go-support` 和 `dev.lithe.plugin.php-support`,签名要求仍是 `publisherPackage`。条目多了 `communityReplacementID`,记录替代它的社区 ID: + +- `publishedEntries` 过滤掉已迁移条目。LSP 控制中心等“官方插件入口”只看这个列表,所以不会再引导用户安装旧包。 +- `PluginMarketplace` 对已迁移 ID 不返回官方来源,也不列出它,即使客户端缓存里还有旧的官方索引。这样旧包不会出现“有更新”,卸载按钮照常可用。 +- `PluginManagementView` 给已迁移的旧包显示迁移提示,写明新的社区 ID。 + +正确做法:保留旧 ID 的签名要求,只去掉它的来源。 + +不要这样做:直接从 `OfficialPluginCatalog` 删除旧 ID。删除后旧 ID 变成普通社区 ID,第三方源就可以发布一个不签名的 `dev.lithe.plugin.php-support` 冒充升级。(市场本身也会忽略第三方源里的 `dev.lithe.*`,但签名要求是最后一道防线。) + +### 3. 用户手动迁移,不自动替换 + +新旧包声明同一个语言(`go` / `php`)。启动时加载器按 ID 排序处理插件包,同一语言只能有一个拥有者:旧包 `dev.lithe.*` 排在前面,所以两者都装着时社区包会因为语言冲突被拒绝。迁移提示因此要求“先卸载旧包,再安装社区版”。 + +不做自动迁移,是因为社区包的信任模型不同(没有 Lithe 签名,需要用户在确认框里看过权限和程序),宿主不能替用户点这个确认。 + +### 4. PHP 插件改用用户安装的 Intelephense + +社区版 PHP 的语言服务器是 `launch.kind: systemCommand`,命令为 `intelephense --stdio`,并声明 `runtimeDependencies: node`。宿主照常发现 Node 并导出 `LITHE_NODE_PATH`,再把 Node 所在目录放到 PATH 最前面,这样从 Dock 启动时 npm 全局安装的 `intelephense` 脚本也能找到 `node`。没有安装时,LSP 状态显示缺少工具,而不是崩溃。 + +### 5. 主仓库的残留 + +- `Plugins/mac/Official/` 只剩随应用发行的 Java Support。Go/PHP 最后一版官方 `plugin.json` 移到 `macos/Tests/LitheTests/Fixtures/RetiredOfficialPlugins/`,只给宿主测试使用。 +- `Plugins/marketplace/official-plugins.json` 和 `index.json` 为空。官方插件发布流程(`build-official-plugins.sh`、`official-plugin-releases.mjs`、`release-official-plugins.yml`)保留给以后的官方插件,测试改用合成的样例插件。 +- `scripts/prepare-php-language-server.sh` 和 `php-language-server-downloads` 工作树资源一并删除。 +- Windows 的 `Plugins/win/Official/PhpSupport` 是另一套 Worker 包格式,这次不动。 + +## 考虑过的备选方案 + +1. 继续作为官方插件,只把源码搬到独立仓库:仍需要 Lithe 私钥签名和官方索引,维护负担没有减轻,也没有解决 Intelephense 再分发的问题。 +2. 沿用旧 ID `dev.lithe.plugin.*-support` 发布社区版:用户无需迁移,但 `dev.lithe.*` 是官方保留命名空间,社区源里的这类条目会被市场忽略;放开限制等于允许任何源冒充官方插件。 +3. 启动时自动卸载旧包并安装社区包:省去用户操作,但跳过了社区插件必须由用户确认的权限和程序审查,也会在后台下载代码。 +4. 社区版继续打包 Intelephense:开箱即用,但违反其许可证。也考虑过换用可再分发的 phpactor,它的启动参数和运行依赖不同,需要单独验证,留给以后。 + +## 后果 + +Go/PHP 插件可以独立迭代和发布,不占用主仓库 CI,也不需要 Lithe 私钥。主仓库少了语言服务器下载和组装脚本。 + +代价: + +- 已安装旧官方包的用户需要手动卸载再安装一次;旧包不会再收到更新。 +- PHP 用户必须自己安装 Intelephense 和 Node.js,首次使用多一步。 +- 社区包没有 Lithe 签名,信任来自 Lithe Store 的审核、SHA-256 和安装确认框。 +- 旧 ID 要一直保留在 `OfficialPluginCatalog` 里,直到确认没有用户再装着旧包。 + +## 验证 + +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginManager|PluginManagementPresentation|PluginPackageStore'`:已迁移 ID 不出现在官方列表、没有官方来源和更新、可以卸载,旧 ID 仍要求 publisher 签名。 +- `node --test scripts/official-plugin-releases.test.mjs`:官方插件配置为空时发布计划为空,发布流程用合成插件继续可测。 +- `node scripts/test-official-plugin-distribution.mjs`:应用内置名单只含 Java Support。 +- `node scripts/test-reuse-worktree-resources.mjs`:已删除的 PHP 下载资源不再出现在复用清单里。 +- `./scripts/verify-platform-feature-matrix.sh`:Go/PHP 功能矩阵的证据路径指向迁移后的文件。 +- `./scripts/verify-agent-notes.sh`:本笔记格式和路径有效。 +- 社区仓库各自的 CI 打包并校验 `plugin.json`;Lithe Store 仓库的索引校验脚本(`validate-index.mjs --verify-archives`)校验索引条目和下载包哈希。 + +## 适用范围 + +- `macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift` +- `macos/Sources/Lithe/Application/Features/PluginMarketplace.swift` +- `macos/Sources/Lithe/Views/App/PluginManagementView.swift` +- `macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift` +- `macos/Tests/LitheTests/Fixtures/RetiredOfficialPlugins/` +- `Plugins/mac/README.md` +- `Plugins/marketplace/` +- `scripts/official-plugin-releases.mjs` +- `scripts/build-official-plugins.sh` diff --git a/.agents/notes/implemented/feature/2026-10-12-plugin-marketplace.md b/.agents/notes/implemented/feature/2026-10-12-plugin-marketplace.md index c67587e7a6..a2462888d8 100644 --- a/.agents/notes/implemented/feature/2026-10-12-plugin-marketplace.md +++ b/.agents/notes/implemented/feature/2026-10-12-plugin-marketplace.md @@ -76,6 +76,7 @@ Lithe 的插件管理改成和 IntelliJ IDEA 一样的“插件市场 / 已安 ## 后果 - 新插件只需要进入某个索引,宿主不用改代码。 +- Go 和 PHP 后来迁到 Lithe Store 社区源,官方索引重新变为空,旧官方 ID 只保留签名要求、不再有官方来源,见 [Go 与 PHP 改为 Lithe Store 社区插件](../architecture/2026-10-20-go-php-community-plugins.md)。 - 首个 PHP 声明式包发布前,官方索引是空的;合入后工作流会立即发布 `plugin-php-support-v1.0.0` 并开 PR 填充索引,客户端刷新时从仓库 HEAD 读取。 - Lithe 版本 Release 不再附带 `Lithe-PHP-Support-<版本>-<架构>.zip`。发版时只需要兼容 0.6.1 及之后的客户端,它们从官方索引下载插件;0.6.0 及更早的客户端不再从 Lithe Release 获得 PHP 包。 - 社区插件的内容摘要只能检测批准后的包内容变化,不能证明作者身份;磁盘每次确认增加一次交互和快照复制成本,确认框必须完整列出程序,不能省略。 diff --git a/Plugins/mac/Official/PhpSupport/language-server.json b/Plugins/mac/Official/PhpSupport/language-server.json deleted file mode 100644 index 15f9ce72c6..0000000000 --- a/Plugins/mac/Official/PhpSupport/language-server.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "schemaVersion": 1, - "pluginID": "dev.lithe.plugin.php-support", - "toolID": "intelephense", - "version": "1.15.1", - "archiveURL": "https://registry.npmjs.org/intelephense/-/intelephense-1.15.1.tgz", - "archiveSHA256": "24a33fe3cd7a2382f44285e2ae553a7e1c898cbc208ee20cee0693497ee9ebe2", - "archiveFormat": "tarGzip", - "archiveRoot": "package", - "entrypoint": "lib/intelephense.js", - "license": "LICENSE.txt" -} diff --git a/Plugins/mac/README.md b/Plugins/mac/README.md index a9f9a3a27c..d84576f4e6 100644 --- a/Plugins/mac/README.md +++ b/Plugins/mac/README.md @@ -11,53 +11,55 @@ does not need to be rebuilt for each Lithe release; compatibility is decided by | Package | Distribution | Language server | | --- | --- | --- | -| `GoSupport` | Downloaded from Plugin Management, publisher-signed | `gopls` from the user's tools; needs Go | -| `PhpSupport` | Downloaded from Plugin Management, publisher-signed | Intelephense packaged in the plugin; needs Node.js | +| `JavaSupport` | Bundled in Lithe.app, same Team ID as the host | JDT LS packaged with Lithe | -No official plugin is bundled in Lithe.app. Both packages are released through -their own tag series (`plugin-go-support-v`, -`plugin-php-support-v`) and listed in the official marketplace index; -see `Plugins/marketplace/official-plugins.json`. +## Go and PHP moved to Lithe Store -Packages are architecture-neutral. `--triple` only selects the SwiftPM layout -for the default output directory and the package signer. +Go Support and PHP Support are no longer official plugins. They are community +plugins in their own repositories and are listed in +[Lithe Store](https://github.com/Lithe-IDEA/Lithe-store): -## Building a downloadable plugin +| Plugin ID | Repository | Language server | +| --- | --- | --- | +| `io.github.lithe-idea.go-support` | [lithe-plugin-go](https://github.com/Lithe-IDEA/lithe-plugin-go) | `gopls` from the user's tools; needs Go | +| `io.github.lithe-idea.php-support` | [lithe-plugin-php](https://github.com/Lithe-IDEA/lithe-plugin-php) | Intelephense installed by the user (`npm install -g intelephense`); needs Node.js | -Official plugins are built separately and are not included in Lithe.app. PHP -Support is the example below; pass `dev.lithe.plugin.go-support` to build Go -Support the same way (it packages only `plugin.json`, so no language-server -download happens): +The PHP plugin does not bundle Intelephense because the Intelephense licence +does not allow redistribution. -```sh -LITHE_PLUGIN_PACKAGE_PRIVATE_KEY="$(cat /secure/path/lithe-plugin-package-private-key.base64)" \ - scripts/build-official-plugins.sh --configuration release \ - --triple arm64-apple-macosx --plugin-id dev.lithe.plugin.php-support -``` +The retired official IDs `dev.lithe.plugin.go-support` and +`dev.lithe.plugin.php-support` stay in `OfficialPluginCatalog` so an installed +copy is still verified against its publisher signature. Plugin Management shows +those copies as moved to the community, offers no official update, and asks the +user to uninstall them before installing the community version. Their last +manifests live in `macos/Tests/LitheTests/Fixtures/RetiredOfficialPlugins/` for +tests only. + +## Downloadable official plugins -The build downloads the Intelephense archive pinned by `language-server.json`, -verifies its SHA-256, and assembles it under `language-server/` in the package -before signing. Keep the resulting `dev.lithe.plugin.php-support` directory -intact for offline recovery; normal users download, install, reinstall, and -uninstall them from Plugin Management. A publisher-signed package requires -`LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`, a base64-encoded 32-byte Ed25519 private key -whose public key matches the embedded publisher key; debug builds skip these -packages when the key is absent, and a direct package build fails instead of -producing an unsigned package. Keep the key in a protected file or environment variable and -never commit it. +No downloadable official plugin is configured right now: +`Plugins/marketplace/official-plugins.json` and `Plugins/marketplace/index.json` +are empty. The release flow (`scripts/build-official-plugins.sh`, +`scripts/official-plugin-releases.mjs`, and +`.github/workflows/release-official-plugins.yml`) is kept for future official +plugins. A new one gets its own directory under `Official/`, its own tag series +(`plugin--v`), and an entry in `official-plugins.json`. -Users who installed the retired native PHP package (0.3.0) see "Update required" -in Plugin Management. Lithe no longer loads that package; Download and Install -replaces it with the declarative package and keeps the user's enablement. +A publisher-signed package requires `LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`, a +base64-encoded 32-byte Ed25519 private key whose public key matches the embedded +publisher key; debug builds skip these packages when the key is absent, and a +direct package build fails instead of producing an unsigned package. Keep the +key in a protected file or environment variable and never commit it. ## Local development packages Plugin authors can test without the publisher key. Build an unsigned -development package, then install it from disk in a debug build of Lithe: +development package with `--development-unsigned`, then install it from disk in +a debug build of Lithe: ```sh scripts/build-official-plugins.sh --configuration debug \ - --triple arm64-apple-macosx --plugin-id dev.lithe.plugin.php-support \ + --triple arm64-apple-macosx --plugin-id \ --development-unsigned --output /tmp/lithe-dev-plugins ``` @@ -67,7 +69,4 @@ installed again. Release builds of Lithe refuse to load it, and the flag is rejected for release plugin builds. A package that does contain a signature document is always verified, whichever way it was installed. -Node.js is required at runtime because Intelephense is a Node.js program; Lithe -discovers it and passes it as `LITHE_NODE_PATH`. Install PHP/Composer and project -PHPUnit only when using run/test. Plugins never delete those user tools or -project files. +Plugins never delete user tools or project files. diff --git a/Plugins/marketplace/index.json b/Plugins/marketplace/index.json index 8e19419384..eeb1ecbf10 100644 --- a/Plugins/marketplace/index.json +++ b/Plugins/marketplace/index.json @@ -1,71 +1,4 @@ { "schemaVersion": 1, - "plugins": [ - { - "id": "dev.lithe.plugin.go-support", - "name": "Go Support", - "author": "Lithe", - "repository": "https://github.com/Lithe-IDEA/Lithe-IDEA", - "description": "Go language intelligence through gopls, plus running Go files and go test. Requires Go and gopls on this Mac.", - "localizedDescriptions": { - "zh-Hans": "通过 gopls 提供 Go 代码智能,并支持运行 Go 文件和 go test。需要本机已安装 Go 和 gopls。" - }, - "tags": [ - "language", - "go", - "gopls" - ], - "license": "Apache-2.0", - "versions": [ - { - "version": "1.0.0", - "pluginAPIVersion": 1, - "releaseDate": "2026-10-13", - "downloads": [ - { - "platforms": [ - "darwin-arm64", - "darwin-x64" - ], - "downloadURL": "https://github.com/Lithe-IDEA/Lithe-IDEA/releases/download/plugin-go-support-v1.0.0/Lithe-Go-Support-1.0.0-darwin.zip", - "sha256": "b0ab36c4fa77e275721ee7ca1b9e092ac51ec17ff1f83c6ad5396822ad01b196" - } - ] - } - ] - }, - { - "id": "dev.lithe.plugin.php-support", - "name": "PHP Support", - "author": "Lithe", - "repository": "https://github.com/Lithe-IDEA/Lithe-IDEA", - "description": "PHP language intelligence through the bundled Intelephense language server, plus running PHP files and PHPUnit tests. Requires Node.js and PHP on this Mac.", - "localizedDescriptions": { - "zh-Hans": "通过内置的 Intelephense 语言服务器提供 PHP 代码智能,并支持运行 PHP 文件和 PHPUnit 测试。需要本机已安装 Node.js 和 PHP。" - }, - "tags": [ - "language", - "php", - "phpunit" - ], - "license": "Apache-2.0", - "versions": [ - { - "version": "1.0.0", - "pluginAPIVersion": 1, - "releaseDate": "2026-10-13", - "downloads": [ - { - "platforms": [ - "darwin-arm64", - "darwin-x64" - ], - "downloadURL": "https://github.com/Lithe-IDEA/Lithe-IDEA/releases/download/plugin-php-support-v1.0.0/Lithe-PHP-Support-1.0.0-darwin.zip", - "sha256": "62b181ff7755bc4ef704d028debfd299bb111afd49d5035a7e03da6d13d7cc74" - } - ] - } - ] - } - ] + "plugins": [] } diff --git a/Plugins/marketplace/official-plugins.json b/Plugins/marketplace/official-plugins.json index eb32579186..25d9ad23f3 100644 --- a/Plugins/marketplace/official-plugins.json +++ b/Plugins/marketplace/official-plugins.json @@ -1,42 +1,5 @@ { "schemaVersion": 1, "description": "Official plugins released through their own tag series and listed in index.json. A plugin is released when its packageDirectory changes and its plugin.json version has no tag yet; changing the directory without a new version fails CI.", - "plugins": [ - { - "id": "dev.lithe.plugin.php-support", - "tagPrefix": "plugin-php-support-v", - "packageDirectory": "Plugins/mac/Official/PhpSupport", - "assetName": "Lithe-PHP-Support-{version}-darwin.zip", - "platforms": ["darwin-arm64", "darwin-x64"], - "index": { - "name": "PHP Support", - "author": "Lithe", - "repository": "https://github.com/Lithe-IDEA/Lithe-IDEA", - "description": "PHP language intelligence through the bundled Intelephense language server, plus running PHP files and PHPUnit tests. Requires Node.js and PHP on this Mac.", - "localizedDescriptions": { - "zh-Hans": "通过内置的 Intelephense 语言服务器提供 PHP 代码智能,并支持运行 PHP 文件和 PHPUnit 测试。需要本机已安装 Node.js 和 PHP。" - }, - "tags": ["language", "php", "phpunit"], - "license": "Apache-2.0" - } - }, - { - "id": "dev.lithe.plugin.go-support", - "tagPrefix": "plugin-go-support-v", - "packageDirectory": "Plugins/mac/Official/GoSupport", - "assetName": "Lithe-Go-Support-{version}-darwin.zip", - "platforms": ["darwin-arm64", "darwin-x64"], - "index": { - "name": "Go Support", - "author": "Lithe", - "repository": "https://github.com/Lithe-IDEA/Lithe-IDEA", - "description": "Go language intelligence through gopls, plus running Go files and go test. Requires Go and gopls on this Mac.", - "localizedDescriptions": { - "zh-Hans": "通过 gopls 提供 Go 代码智能,并支持运行 Go 文件和 go test。需要本机已安装 Go 和 gopls。" - }, - "tags": ["language", "go", "gopls"], - "license": "Apache-2.0" - } - } - ] + "plugins": [] } diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 7e49304d1d..4b7b4c8f1f 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -233,7 +233,7 @@ gh workflow run release-preview-macos.yml \ `Plugins/marketplace/official-plugins.json` 和 `index.json` 固定到本次临时目录, 按数值语义版本选择每个已注册可下载插件的最新版本,保留全部下载变体并去重。 只下载索引指向的 `Lithe-IDEA` 组织 GitHub Release ZIP;仓库重命名重定向由 gh -处理。当前 PHP/Go 1.0.0 可下载,内置 Java 不属于镜像。先按索引 SHA-256 +处理。Go/PHP 已迁移为 Lithe Store 社区插件,当前没有可下载官方插件,内置 Java 也不属于镜像。先按索引 SHA-256 验证原始 ZIP,再生成旁路 checksum;不构建、重签插件,也不需要插件私钥。 索引和上游 canonical URL 不修改,AutoBuild 只是方便手动下载的镜像架。 @@ -440,9 +440,6 @@ SHA-256;Cargo、SwiftPM 和 Bun 使用各自的 lockfile、版本与完整性 worker 内存中存活,不增加下载目录,不写发行资源,也不影响签名或增量更新。 - `.artifacts/jdtls-downloads/`:JDTLS、Lombok、Java Debug/Test 和 license。 - `.artifacts/jdk-downloads/`:各平台与架构的 bundled JDK 下载归档。 -- `.artifacts/php-language-server-downloads/`:按 - `Plugins/mac/Official/PhpSupport/language-server.json` 下载并校验的 - Intelephense tarball;它只服务当前工作树的插件打包,不能复制解压结果。 Inter 4.1 的 18 个静态 OTF(内部版本 4.001)及许可、JetBrains Mono 2.304 的 16 个静态 TTF、OFL 和作者信息位于 Git 跟踪的 `macos/Resources/Fonts`。同目录增加 Nerd Fonts v3.5.1 的 JetBrains Mono Nerd Font Mono 四个完整 TTF(常规、粗体、斜体、粗斜体)及 OFL 许可,终端可直接选择这些字型而不依赖用户安装;来源、版本和固定 SHA-256 记录于 `NOTICE.txt`。它们与平台架构和工具链无关,随工作树检出,不从另一个 @@ -575,8 +572,8 @@ Inter 4.1 的 18 个静态 OTF(内部版本 4.001)及许可、JetBrains Mono identity stamp,任何复制阶段都禁止共享。资源清单 `jdt-maven-settings` 显式排除, 复用脚本直接拒绝该资源,不进入下载或生成物校验路由。 -官方插件包(Go、PHP)是声明式插件包(只含 `plugin.json` 和它声明的资源,不含宿主加载的代码,与架构无关;自带运行时的 Java Support 见下文),在 `.build///OfficialPlugins` 中独立组装,`--triple` 只决定输出目录和签名工具的构建布局;组装后通过 `LitheOfficialPluginVerifier` 验证声明、打包的可执行文件和“Mach-O 原生代码只出现在清单声明的运行时目录内”;发布配置还要求 repository secret `LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`,由 `LithePluginPackageSigner` 对完整包生成 `lithe-plugin-signature.json`,客户端按宿主内置官方插件策略和 publisher 公钥验证后才允许安装。该 secret 的值是与客户端内置公钥匹配的 base64 编码 32 字节 Ed25519 私钥,可用 `gh secret set LITHE_PLUGIN_PACKAGE_PRIVATE_KEY --repo 1lck/Lithe-IDEA < /secure/path/lithe-plugin-package-private-key.base64` 配置;私钥文件和 shell 历史都不得进入仓库或日志。没有该 key 时,普通 debug 全量构建会跳过 PHP 和 Go 包,指定其中一个包 ID 的构建会失败,不会留下缺少签名文档的目录。本地调试插件时可以显式传入 `--development-unsigned`(仅 debug 配置可用),生成不含签名文档的开发包,只能由 debug 构建的 Lithe 从磁盘安装;这类开发包同样不跨工作树复用。无可靠 identity stamp,不跨工作树复制。插件安装后的 Intelephense 位于 -`/Lithe/Plugins//versions//language-server`,由插件版本目录拥有,运行时只读,重装、回滚和卸载随插件一起处理,不是工作树构建缓存。PHPUnit 测试夹具的 `shared/fixtures/phpunit-project/vendor` 也由当前工作树独立安装。以上项目在资源清单 `excludedResources` 中明确排除,复用脚本会拒绝显式复制请求。 +可下载的官方插件包是声明式插件包(只含 `plugin.json` 和它声明的资源,不含宿主加载的代码,与架构无关;自带运行时的 Java Support 见下文),在 `.build///OfficialPlugins` 中独立组装,`--triple` 只决定输出目录和签名工具的构建布局;组装后通过 `LitheOfficialPluginVerifier` 验证声明、打包的可执行文件和“Mach-O 原生代码只出现在清单声明的运行时目录内”;发布配置还要求 repository secret `LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`,由 `LithePluginPackageSigner` 对完整包生成 `lithe-plugin-signature.json`,客户端按宿主内置官方插件策略和 publisher 公钥验证后才允许安装。该 secret 的值是与客户端内置公钥匹配的 base64 编码 32 字节 Ed25519 私钥,可用 `gh secret set LITHE_PLUGIN_PACKAGE_PRIVATE_KEY --repo 1lck/Lithe-IDEA < /secure/path/lithe-plugin-package-private-key.base64` 配置;私钥文件和 shell 历史都不得进入仓库或日志。没有该 key 时,普通 debug 全量构建会跳过 publisher 签名包,指定其中一个包 ID 的构建会失败,不会留下缺少签名文档的目录。本地调试插件时可以显式传入 `--development-unsigned`(仅 debug 配置可用),生成不含签名文档的开发包,只能由 debug 构建的 Lithe 从磁盘安装;这类开发包同样不跨工作树复用。无可靠 identity stamp,不跨工作树复制。插件安装后打包的语言服务器位于 +`/Lithe/Plugins//versions//language-server`,由插件版本目录拥有,运行时只读,重装、回滚和卸载随插件一起处理,不是工作树构建缓存。Go Support 和 PHP Support 已迁移为 Lithe Store 社区插件(`Lithe-IDEA/lithe-plugin-go`、`Lithe-IDEA/lithe-plugin-php`),不再由本仓库构建。PHPUnit 测试夹具的 `shared/fixtures/phpunit-project/vendor` 也由当前工作树独立安装。以上项目在资源清单 `excludedResources` 中明确排除,复用脚本会拒绝显式复制请求。 Java Support(`dev.lithe.plugin.java-support`)是唯一随 Lithe.app 发行的官方插件包, 也是唯一自带运行时的包,因此它与架构有关: diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index ae0e1aae3c..5fb7af00f4 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -2848,10 +2848,11 @@ "Update required" = "需要更新"; -"Adds PHP language-server integration, formatting, running, and test support." = "提供 PHP 语言服务器集成、格式化、运行和测试支持。"; +"Retired official PHP plugin. PHP support is now the community plugin PHP Support in Lithe Store." = "已退役的官方 PHP 插件。PHP 支持现在由 Lithe Store 中的社区插件 PHP Support 提供。"; "Adds Java language intelligence through the Eclipse JDT language server, with Lombok, debugging, and test support. Ships its own Java runtime." = "通过 Eclipse JDT 语言服务器提供 Java 代码智能,并支持 Lombok、调试和测试。自带 Java 运行时。"; -"Adds Go language-server integration through gopls, running, and test support. Install gopls and Go separately." = "通过 gopls 提供 Go 语言服务器集成、运行和测试支持。需要另行安装 gopls 和 Go。"; +"Retired official Go plugin. Go support is now the community plugin Go Support in Lithe Store." = "已退役的官方 Go 插件。Go 支持现在由 Lithe Store 中的社区插件 Go Support 提供。"; +"%@ is no longer an official plugin. It moved to the community plugin %@ in Lithe Store and gets no further updates here. Uninstall it, then install the community version from the Marketplace." = "%@ 已不再是官方插件,已迁移为 Lithe Store 中的社区插件 %@,这里不会再收到更新。请先卸载它,再从插件市场安装社区版本。"; "Download PHP Support from the official signed plugin package." = "从官方签名的插件包下载 PHP 支持。"; diff --git a/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift b/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift index b07d0668e2..39710b82e3 100644 --- a/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift +++ b/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift @@ -419,13 +419,17 @@ final class PluginMarketplaceFeatureModel: ObservableObject { /// The marketplace source a plugin was installed from. Official packages /// installed before the marketplace existed belong to the official source. + /// An official plugin that moved to the community has no source left: the + /// official index no longer lists it, so it offers no update or reinstall. func installedSourceID(for pluginID: PluginID) -> String? { guard let snapshot = pluginManager.snapshots.first(where: { $0.id == pluginID }) else { return nil } guard snapshot.disk == nil else { return nil } + let official = OfficialPluginCatalog.entry(for: pluginID) + guard official?.communityReplacementID == nil else { return nil } if let provenance = snapshot.marketplace { return provenance.sourceID } - if snapshot.origin == .marketplace, OfficialPluginCatalog.entry(for: pluginID) != nil { + if snapshot.origin == .marketplace, official != nil { return PluginMarketplaceSource.officialID } return nil @@ -586,7 +590,10 @@ final class PluginMarketplaceFeatureModel: ObservableObject { // Source order is the user's list order with the official source // first, so it is also the default choice for a shared ID. for source in sources where source.isEnabled { - for entry in indexes[source.id]?.plugins ?? [] { + // A cached official index from before a plugin moved to the + // community may still list it; its successor is installed instead. + for entry in indexes[source.id]?.plugins ?? [] + where OfficialPluginCatalog.entry(for: PluginID(entry.id))?.communityReplacementID == nil { offersByID[entry.id, default: []].append(PluginMarketplaceOffer(source: source, entry: entry)) } } diff --git a/macos/Sources/Lithe/Views/App/PluginManagementView.swift b/macos/Sources/Lithe/Views/App/PluginManagementView.swift index 139d73165c..dc27acb2df 100644 --- a/macos/Sources/Lithe/Views/App/PluginManagementView.swift +++ b/macos/Sources/Lithe/Views/App/PluginManagementView.swift @@ -451,6 +451,23 @@ private struct InstalledPluginsView: View { } .font(LitheTheme.smallFont) } + if let replacementID = OfficialPluginCatalog.entry(for: plugin.id)?.communityReplacementID { + Label( + String( + format: PluginMarketplacePresentation.localized( + "%@ is no longer an official plugin. It moved to the community plugin %@ in Lithe Store and gets no further updates here. Uninstall it, then install the community version from the Marketplace.", + locale: locale + ), + plugin.manifest.displayName, + replacementID.rawValue + ), + systemImage: "arrow.triangle.branch" + ) + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.warning) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } if plugin.origin == .community { Label( LocalizedStringKey(plugin.disk == nil @@ -589,7 +606,7 @@ struct PluginManagementListContent { Presentation( systemImage: "chevron.left.forwardslash.chevron.right", tint: LitheTheme.accent, - summary: "Adds Go language-server integration through gopls, running, and test support. Install gopls and Go separately." + summary: "Retired official Go plugin. Go support is now the community plugin Go Support in Lithe Store." ) case OfficialPluginCatalog.javaPluginID: Presentation( @@ -601,7 +618,7 @@ struct PluginManagementListContent { Presentation( systemImage: "globe", tint: LitheTheme.success, - summary: "Adds PHP language-server integration, formatting, running, and test support." + summary: "Retired official PHP plugin. PHP support is now the community plugin PHP Support in Lithe Store." ) default: nil diff --git a/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift b/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift index d0fa55a60f..feb7b40331 100644 --- a/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift +++ b/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift @@ -139,10 +139,10 @@ struct LSPControlCenterView: View { } } - // Languages owned by an official plugin are controlled here only once + // Languages owned by a published official plugin are controlled here only once // that plugin is installed, enabled, and loaded in this process. private func officialPluginEntry(languageID: String) -> OfficialPluginCatalog.Entry? { - OfficialPluginCatalog.entries.first { $0.languageID == languageID } + OfficialPluginCatalog.publishedEntries.first { $0.languageID == languageID } } private func pluginSnapshot(_ entry: OfficialPluginCatalog.Entry) -> PluginManagementSnapshot? { @@ -162,7 +162,7 @@ struct LSPControlCenterView: View { } private var pluginNotices: [String] { - OfficialPluginCatalog.entries.compactMap { entry in + OfficialPluginCatalog.publishedEntries.compactMap { entry in guard projectUses(entry), !pluginIsReady(entry) else { return nil } return pluginNotice(entry) } diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index 110f7c8e05..3e41a999ab 100644 --- a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -213,13 +213,19 @@ public enum BuiltInPluginCatalog { /// Official declarative plugins the host recognizes before they are installed. /// The host pins each plugin's trust policy so a package cannot downgrade the /// signature it must carry; the package's own `plugin.json` remains the source -/// of its language and process declarations. +/// of its language and process declarations. Retired official plugins stay +/// listed so installed copies keep that pinned trust policy. public enum OfficialPluginCatalog { public enum Distribution: Equatable, Sendable { /// Shipped inside the application bundle and sealed by its signature. case bundled /// Downloaded on demand as a publisher-signed package. case download + /// No longer published as an official plugin; its successor is the + /// community plugin with this ID. An installed copy keeps its pinned + /// signature requirement and keeps loading, but receives no official + /// updates, so the host asks the user to replace it. + case migratedToCommunity(PluginID) } public struct Entry: Equatable, Sendable { @@ -232,6 +238,12 @@ public enum OfficialPluginCatalog { /// Used to suggest the plugin for a project before it is installed. public let fileExtensions: [String] public let projectFileNames: [String] + + /// The community plugin that replaces this retired official plugin. + public var communityReplacementID: PluginID? { + guard case .migratedToCommunity(let replacementID) = distribution else { return nil } + return replacementID + } } public static let phpPluginID = PluginID("dev.lithe.plugin.php-support") @@ -253,7 +265,7 @@ public enum OfficialPluginCatalog { id: goPluginID, displayName: "Go Support", signatureRequirement: .publisherPackage, - distribution: .download, + distribution: .migratedToCommunity(PluginID("io.github.lithe-idea.go-support")), languageID: "go", languageDisplayName: "Go", fileExtensions: ["go"], @@ -263,7 +275,7 @@ public enum OfficialPluginCatalog { id: phpPluginID, displayName: "PHP Support", signatureRequirement: .publisherPackage, - distribution: .download, + distribution: .migratedToCommunity(PluginID("io.github.lithe-idea.php-support")), languageID: "php", languageDisplayName: "PHP", fileExtensions: ["php", "phtml"], @@ -274,6 +286,12 @@ public enum OfficialPluginCatalog { public static func entry(for id: PluginID) -> Entry? { entries.first { $0.id == id } } + + /// Entries still published as official plugins, which the host may + /// suggest and whose languages it ties to the official package. + public static var publishedEntries: [Entry] { + entries.filter { $0.communityReplacementID == nil } + } } /// Module manifests the host synthesizes for language capabilities, shared by diff --git a/macos/Tests/LitheTests/DeclarativePluginTestSupport.swift b/macos/Tests/LitheTests/DeclarativePluginTestSupport.swift index fe92aabbac..078631dbe9 100644 --- a/macos/Tests/LitheTests/DeclarativePluginTestSupport.swift +++ b/macos/Tests/LitheTests/DeclarativePluginTestSupport.swift @@ -4,7 +4,9 @@ import LitheModuleAPI @testable import Lithe /// Loads official declarative plugins from the repository through the same -/// Rust Core validation the application uses. +/// Rust Core validation the application uses. Go and PHP Support moved to +/// community plugins; their last official manifests stay as fixtures because +/// installed copies keep the official trust policy. enum DeclarativePluginTestSupport { static let repositoryRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -16,11 +18,19 @@ enum DeclarativePluginTestSupport { RustDeclarativePluginOperations(core: RustCoreBridge()) } - static func officialManifestData(_ directoryName: String) throws -> Data { - try Data(contentsOf: repositoryRoot - .appendingPathComponent("Plugins/mac/Official", isDirectory: true) + static let retiredOfficialPackages: Set = ["GoSupport", "PhpSupport"] + + static func officialPackageURL(_ directoryName: String) -> URL { + let parent = retiredOfficialPackages.contains(directoryName) + ? "macos/Tests/LitheTests/Fixtures/RetiredOfficialPlugins" + : "Plugins/mac/Official" + return repositoryRoot + .appendingPathComponent(parent, isDirectory: true) .appendingPathComponent(directoryName, isDirectory: true) - .appendingPathComponent("plugin.json")) + } + + static func officialManifestData(_ directoryName: String) throws -> Data { + try Data(contentsOf: officialPackageURL(directoryName).appendingPathComponent("plugin.json")) } static func officialDescriptor(_ directoryName: String) throws -> DeclarativePluginDescriptor { @@ -122,7 +132,7 @@ enum DeclarativePluginTestSupport { activeVersion: descriptor.pluginManifest.version, origin: .bundled ), - packageURL: repositoryRoot.appendingPathComponent("Plugins/mac/Official/\(directoryName)") + packageURL: officialPackageURL(directoryName) ) return MacServiceContainer.declarativePluginFactories( [installed], diff --git a/Plugins/mac/Official/GoSupport/plugin.json b/macos/Tests/LitheTests/Fixtures/RetiredOfficialPlugins/GoSupport/plugin.json similarity index 100% rename from Plugins/mac/Official/GoSupport/plugin.json rename to macos/Tests/LitheTests/Fixtures/RetiredOfficialPlugins/GoSupport/plugin.json diff --git a/Plugins/mac/Official/PhpSupport/plugin.json b/macos/Tests/LitheTests/Fixtures/RetiredOfficialPlugins/PhpSupport/plugin.json similarity index 100% rename from Plugins/mac/Official/PhpSupport/plugin.json rename to macos/Tests/LitheTests/Fixtures/RetiredOfficialPlugins/PhpSupport/plugin.json diff --git a/macos/Tests/LitheTests/PluginManagerTests.swift b/macos/Tests/LitheTests/PluginManagerTests.swift index 278c87b723..5f285a7ae5 100644 --- a/macos/Tests/LitheTests/PluginManagerTests.swift +++ b/macos/Tests/LitheTests/PluginManagerTests.swift @@ -44,6 +44,16 @@ struct PluginManagerTests { try DeclarativePluginTestSupport.officialDescriptor($0).pluginManifest } #expect(Set(officialLanguagePlugins.map(\.id)) == Set(OfficialPluginCatalog.entries.map(\.id))) + // Go and PHP moved to Lithe Store: installed copies keep their pinned + // publisher signature, but the host no longer suggests them. + #expect(OfficialPluginCatalog.publishedEntries.map(\.id) == [OfficialPluginCatalog.javaPluginID]) + #expect(OfficialPluginCatalog.entry(for: OfficialPluginCatalog.goPluginID)?.communityReplacementID + == PluginID("io.github.lithe-idea.go-support")) + #expect(OfficialPluginCatalog.entry(for: OfficialPluginCatalog.phpPluginID)?.communityReplacementID + == PluginID("io.github.lithe-idea.php-support")) + #expect([OfficialPluginCatalog.goPluginID, OfficialPluginCatalog.phpPluginID].allSatisfy { + OfficialPluginCatalog.entry(for: $0)?.signatureRequirement == .publisherPackage + }) #expect(officialLanguagePlugins.flatMap(\.modules).allSatisfy { $0.manifest.defaultState == .disabled }) diff --git a/macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift b/macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift index adbed2b231..6d28caf614 100644 --- a/macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift +++ b/macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift @@ -295,6 +295,45 @@ struct PluginMarketplaceFeatureModelTests { #expect(feature.installState(for: offer) == .managedOutsideMarketplace(version: "0.9.0")) } + /// Go and PHP Support moved to Lithe Store. A cached official index that + /// still lists the old package must not offer it, and an installed copy + /// is no longer tied to the official source for updates or reinstall. + @Test + func officialPluginMovedToTheCommunityHasNoOfficialSource() throws { + let retiredID = OfficialPluginCatalog.phpPluginID + let (feature, _, manager) = try makeModel(staleOfficialIDs: [retiredID.rawValue]) + let manifest = try DeclarativePluginTestSupport.officialDescriptor("PhpSupport").pluginManifest + manager.snapshots = [PluginManagementSnapshot( + manifest: manifest, + origin: .marketplace, + installationStatus: .installed, + isEnabled: true, + isRequired: false, + isRunning: false, + isQuarantined: false, + isSuppressedBySafeMode: false, + requiresRestart: false, + canRollback: false, + statusMessage: "Enabled", + marketplace: PluginMarketplaceProvenance( + sourceID: PluginMarketplaceSource.officialID, + sourceName: "Lithe", + repository: "https://github.com/Lithe-IDEA/Lithe-IDEA", + archiveSHA256: String(repeating: "1", count: 64), + contentDigest: String(repeating: "2", count: 64), + approvedPrograms: [] + ) + )] + + #expect(OfficialPluginCatalog.entry(for: retiredID)?.communityReplacementID + == PluginID("io.github.lithe-idea.php-support")) + #expect(!feature.listings.contains { $0.pluginID == retiredID }) + #expect(feature.listings.contains { $0.pluginID.rawValue == "dev.lithe.plugin.sample" }) + #expect(feature.installedSourceID(for: retiredID) == nil) + #expect(feature.availableUpdate(for: retiredID) == nil) + #expect(feature.canUninstall(retiredID)) + } + @Test func aNewApprovalDiscardsThePreviousSnapshot() async throws { let (feature, service, _) = try makeModel() @@ -334,12 +373,15 @@ struct PluginMarketplaceFeatureModelTests { private func makeModel( alsoListCommunityInOfficial: Bool = false, + staleOfficialIDs: [String] = [], communitySource community: PluginMarketplaceSource = Self.community ) throws -> (PluginMarketplaceFeatureModel, FakeMarketplaceService, FakePluginManager) { let official = MacPluginMarketplaceService.officialSource var officialEntries = [ Self.entry(id: "dev.lithe.plugin.sample", date: "2026-09-01", platforms: ["darwin-arm64"]) - ] + ] + staleOfficialIDs.map { + Self.entry(id: $0, date: "2026-10-13", platforms: ["darwin-arm64"]) + } if alsoListCommunityInOfficial { officialEntries.append(Self.entry(id: "io.example.zig-support", date: "2026-10-01", platforms: ["darwin-arm64"])) } diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index 64c512d5cc..e946df378e 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -29,14 +29,6 @@ const MIX_PROVIDER_FIXTURE: &str = contract_file!("fixtures/mix-provider.plugin. const INVALID_MANIFESTS: &str = contract_file!("fixtures/invalid-manifests.json"); const COMMAND_PLANS: &str = contract_file!("fixtures/command-plans.json"); const RUNTIME_VERSIONS: &str = contract_file!("fixtures/runtime-versions.json"); -const OFFICIAL_PHP: &str = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../Plugins/mac/Official/PhpSupport/plugin.json" -)); -const OFFICIAL_GO: &str = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../Plugins/mac/Official/GoSupport/plugin.json" -)); const OFFICIAL_JAVA: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../Plugins/mac/Official/JavaSupport/plugin.json" @@ -72,8 +64,6 @@ fn contract_fixtures_and_official_plugins_validate() { GO_FIXTURE, HELLO_FIXTURE, WEB_PANEL_FIXTURE, - OFFICIAL_PHP, - OFFICIAL_GO, JAVA_FIXTURE, NODE_RUNTIME_FIXTURE, OFFICIAL_JAVA, diff --git a/scripts/build-official-plugins.sh b/scripts/build-official-plugins.sh index 7254f9b435..b35b7843f9 100755 --- a/scripts/build-official-plugins.sh +++ b/scripts/build-official-plugins.sh @@ -109,15 +109,6 @@ for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do rm -rf "$package_dir" mkdir -p "$package_dir" cp "$manifest" "$package_dir/plugin.json" - # A pinned language-server source is fetched, checksum-verified, and - # assembled at build time. The installed package is never modified. - language_server_manifest="$plugin_source/language-server.json" - if [[ -f "$language_server_manifest" ]]; then - cp "$language_server_manifest" "$package_dir/language-server.json" - "$ROOT_DIR/scripts/prepare-php-language-server.sh" \ - --manifest "$language_server_manifest" \ - --output "$package_dir/language-server" >&2 - fi # A plugin that needs more than one pinned archive keeps its assembly # next to its manifest, so the steps that are specific to one plugin are diff --git a/scripts/official-plugin-distribution.mjs b/scripts/official-plugin-distribution.mjs index 662f8e7fba..f29ce82b96 100644 --- a/scripts/official-plugin-distribution.mjs +++ b/scripts/official-plugin-distribution.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // A package must opt into the base application; all other official plugins // remain separately buildable and installable through plugin management. -// Go Support and PHP Support are downloaded from the plugin marketplace. Java +// Go Support and PHP Support moved to community plugins in Lithe Store. Java // Support ships inside Lithe.app so Java works offline on first launch; its // manifest uses `sameTeamAsHost`, and the user can remove and restore it in // plugin management. diff --git a/scripts/official-plugin-releases.mjs b/scripts/official-plugin-releases.mjs index b71edc7383..8537b37086 100644 --- a/scripts/official-plugin-releases.mjs +++ b/scripts/official-plugin-releases.mjs @@ -54,7 +54,11 @@ function directoryChangedSince(tag, directory) { } } -export function releaseFor(plugin, readManifest = (dir) => JSON.parse(readFileSync(join(root, dir, "plugin.json"), "utf8"))) { +function readPackageManifest(directory) { + return JSON.parse(readFileSync(join(root, directory, "plugin.json"), "utf8")); +} + +export function releaseFor(plugin, readManifest = readPackageManifest) { const manifest = readManifest(plugin.packageDirectory); if (manifest.id !== plugin.id) { throw new Error(`${plugin.packageDirectory}/plugin.json has id ${manifest.id}, expected ${plugin.id}`); @@ -73,8 +77,8 @@ export function releaseFor(plugin, readManifest = (dir) => JSON.parse(readFileSy } /** Plugins whose current version has no tag yet. */ -export function plan(plugins = loadConfig(), exists = tagExists) { - return plugins.map((plugin) => releaseFor(plugin)).filter((release) => !exists(release.tag)); +export function plan(plugins = loadConfig(), exists = tagExists, readManifest = readPackageManifest) { + return plugins.map((plugin) => releaseFor(plugin, readManifest)).filter((release) => !exists(release.tag)); } /** Released (tagged) versions that the official index does not list yet. @@ -106,10 +110,15 @@ function readIndex() { /** Changing a released plugin without a new version would ship two * different packages under one version, so it is an error. */ -export function check(plugins = loadConfig(), exists = tagExists, changed = directoryChangedSince) { +export function check( + plugins = loadConfig(), + exists = tagExists, + changed = directoryChangedSince, + readManifest = readPackageManifest, +) { const problems = []; for (const plugin of plugins) { - const release = releaseFor(plugin); + const release = releaseFor(plugin, readManifest); if (exists(release.tag) && changed(release.tag, release.packageDirectory)) { problems.push(`${release.id}: ${release.packageDirectory} changed since ${release.tag}; bump the version in plugin.json`); } diff --git a/scripts/official-plugin-releases.test.mjs b/scripts/official-plugin-releases.test.mjs index 962f5dfbdc..2732cdf3c6 100644 --- a/scripts/official-plugin-releases.test.mjs +++ b/scripts/official-plugin-releases.test.mjs @@ -2,62 +2,90 @@ import assert from "node:assert/strict"; import test from "node:test"; import { check, loadConfig, pendingIndex, plan, releaseFor, updateIndex } from "./official-plugin-releases.mjs"; -const plugins = loadConfig(); -const php = plugins.find((plugin) => plugin.id === "dev.lithe.plugin.php-support"); +// Go and PHP Support moved to Lithe Store, so no downloadable official plugin +// is configured right now. The release flow is kept for future official +// plugins and tested with a synthetic one. +const sample = { + id: "dev.lithe.plugin.sample-support", + tagPrefix: "plugin-sample-support-v", + packageDirectory: "Plugins/mac/Official/SampleSupport", + assetName: "Lithe-Sample-Support-{version}-darwin.zip", + platforms: ["darwin-arm64", "darwin-x64"], + index: { + name: "Sample Support", + author: "Lithe", + repository: "https://github.com/Lithe-IDEA/Lithe-IDEA", + description: "Sample official plugin.", + tags: ["language"], + license: "Apache-2.0", + }, +}; +const plugins = [sample]; +const readManifest = (directory) => { + assert.equal(directory, sample.packageDirectory); + return { id: sample.id, version: "1.0.0" }; +}; + +test("the repository configuration currently lists no downloadable official plugin", () => { + assert.deepEqual(loadConfig(), []); + assert.deepEqual(plan(), []); + assert.deepEqual(check(), []); + assert.deepEqual(pendingIndex(), []); +}); test("each official plugin has its own tag series", () => { - const release = releaseFor(php); - assert.equal(release.tag, `plugin-php-support-v${release.version}`); - assert.equal(release.assetName, `Lithe-PHP-Support-${release.version}-darwin.zip`); - const prefixes = plugins.map((plugin) => plugin.tagPrefix); - assert.equal(new Set(prefixes).size, prefixes.length); + const release = releaseFor(sample, readManifest); + assert.equal(release.tag, `plugin-sample-support-v${release.version}`); + assert.equal(release.assetName, `Lithe-Sample-Support-${release.version}-darwin.zip`); + assert.throws(() => releaseFor(sample, () => ({ id: "dev.lithe.plugin.other", version: "1.0.0" }))); + assert.throws(() => releaseFor(sample, () => ({ id: sample.id, version: "1.0" }))); }); test("only untagged versions are planned", () => { - assert.equal(plan(plugins, () => true).length, 0); - assert.deepEqual(plan(plugins, () => false).map((release) => release.id), plugins.map((plugin) => plugin.id)); + assert.equal(plan(plugins, () => true, readManifest).length, 0); + assert.deepEqual(plan(plugins, () => false, readManifest).map((release) => release.id), [sample.id]); }); test("changing a released plugin without a new version fails", () => { - assert.deepEqual(check(plugins, () => true, () => false), []); - assert.equal(check(plugins, () => true, () => true).length, plugins.length); + assert.deepEqual(check(plugins, () => true, () => false, readManifest), []); + assert.equal(check(plugins, () => true, () => true, readManifest).length, plugins.length); // A version that was never tagged is released on merge instead. - assert.deepEqual(check(plugins, () => false, () => true), []); + assert.deepEqual(check(plugins, () => false, () => true, readManifest), []); }); test("index update keeps versions newest first and points at the tag asset", () => { const sha = "a".repeat(64); let index = { schemaVersion: 1, plugins: [] }; - index = updateIndex(index, php, { version: "1.0.0", sha256: sha, releaseDate: "2026-10-12" }); - index = updateIndex(index, php, { version: "1.2.0", sha256: sha, releaseDate: "2026-11-01" }); - index = updateIndex(index, php, { version: "1.1.0", sha256: sha, releaseDate: "2026-10-20" }); + index = updateIndex(index, sample, { version: "1.0.0", sha256: sha, releaseDate: "2026-10-12" }); + index = updateIndex(index, sample, { version: "1.2.0", sha256: sha, releaseDate: "2026-11-01" }); + index = updateIndex(index, sample, { version: "1.1.0", sha256: sha, releaseDate: "2026-10-20" }); const entry = index.plugins[0]; assert.deepEqual(entry.versions.map((version) => version.version), ["1.2.0", "1.1.0", "1.0.0"]); assert.equal( entry.versions[0].downloads[0].downloadURL, - "https://github.com/Lithe-IDEA/Lithe-IDEA/releases/download/plugin-php-support-v1.2.0/Lithe-PHP-Support-1.2.0-darwin.zip", + "https://github.com/Lithe-IDEA/Lithe-IDEA/releases/download/plugin-sample-support-v1.2.0/Lithe-Sample-Support-1.2.0-darwin.zip", ); assert.deepEqual(entry.versions[0].downloads[0].platforms, ["darwin-arm64", "darwin-x64"]); - assert.throws(() => updateIndex(index, php, { version: "1.3.0", sha256: "ABC", releaseDate: "2026-11-02" })); + assert.throws(() => updateIndex(index, sample, { version: "1.3.0", sha256: "ABC", releaseDate: "2026-11-02" })); }); test("tagged versions missing from the index are pending, so a failed listing is retried", () => { const sha = "a".repeat(64); - const tags = (prefix) => (prefix === php.tagPrefix + const tags = (prefix) => (prefix === sample.tagPrefix ? [`${prefix}1.0.0`, `${prefix}1.1.0`, `${prefix}not-a-version`] : []); // Nothing listed yet: every released version is pending. assert.deepEqual( pendingIndex(plugins, { schemaVersion: 1, plugins: [] }, tags).map((release) => release.tag), - [`${php.tagPrefix}1.0.0`, `${php.tagPrefix}1.1.0`], + [`${sample.tagPrefix}1.0.0`, `${sample.tagPrefix}1.1.0`], ); // 1.0.0 was listed but 1.1.0's index pull request failed: only 1.1.0 is // retried, with the asset name of that version. - const index = updateIndex({ schemaVersion: 1, plugins: [] }, php, { version: "1.0.0", sha256: sha, releaseDate: "2026-10-12" }); + const index = updateIndex({ schemaVersion: 1, plugins: [] }, sample, { version: "1.0.0", sha256: sha, releaseDate: "2026-10-12" }); const pending = pendingIndex(plugins, index, tags); assert.deepEqual(pending.map((release) => release.version), ["1.1.0"]); - assert.equal(pending[0].assetName, "Lithe-PHP-Support-1.1.0-darwin.zip"); + assert.equal(pending[0].assetName, "Lithe-Sample-Support-1.1.0-darwin.zip"); // Once listed, nothing is pending. - const listed = updateIndex(index, php, { version: "1.1.0", sha256: sha, releaseDate: "2026-10-20" }); + const listed = updateIndex(index, sample, { version: "1.1.0", sha256: sha, releaseDate: "2026-10-20" }); assert.deepEqual(pendingIndex(plugins, listed, tags), []); }); diff --git a/scripts/prepare-php-language-server.sh b/scripts/prepare-php-language-server.sh deleted file mode 100755 index d9c83d884c..0000000000 --- a/scripts/prepare-php-language-server.sh +++ /dev/null @@ -1,125 +0,0 @@ -#!/bin/zsh - -set -euo pipefail - -ROOT_DIR="${0:A:h:h}" -MANIFEST="$ROOT_DIR/Plugins/mac/Official/PhpSupport/language-server.json" -OUTPUT_DIR="" -CACHE_DIR="${LITHE_PHP_LANGUAGE_SERVER_CACHE:-$ROOT_DIR/.artifacts/php-language-server-downloads}" - -while [[ $# -gt 0 ]]; do - case "$1" in - --manifest) MANIFEST="$2"; shift 2 ;; - --output) OUTPUT_DIR="$2"; shift 2 ;; - --cache) CACHE_DIR="$2"; shift 2 ;; - *) print -u2 -- "Usage: $0 [--manifest path] --output directory [--cache directory]"; exit 2 ;; - esac -done - -[[ -f "$MANIFEST" ]] || { print -u2 -- "PHP language-server manifest was not found: $MANIFEST"; exit 1; } -[[ -n "$OUTPUT_DIR" ]] || { print -u2 -- "PHP language-server output directory is required"; exit 2; } - -manifest_value() { - /usr/bin/plutil -extract "$1" raw -o - "$MANIFEST" -} - -archive_url="$(manifest_value archiveURL)" -archive_sha256="$(manifest_value archiveSHA256)" -archive_format="$(manifest_value archiveFormat)" -archive_root="$(manifest_value archiveRoot)" -entrypoint="$(manifest_value entrypoint)" -license_path="$(manifest_value license)" - -[[ "$archive_format" == "tarGzip" ]] || { - print -u2 -- "Unsupported PHP language-server archive format: $archive_format" - exit 1 -} -[[ "$archive_url" == https://* ]] || { print -u2 -- "PHP language-server archive URL must use HTTPS"; exit 1; } -print -r -- "$archive_sha256" | /usr/bin/grep -Eq '^[0-9A-Fa-f]{64}$' || { - print -u2 -- "PHP language-server checksum must be a 64-character SHA-256 value" - exit 1 -} - -file_sha256() { - shasum -a 256 "$1" | awk '{print tolower($1)}' -} - -download_verified_file() { - local url="$1" - local expected_sha256="$2" - local destination="$3" - local description="$4" - local temporary_path="$destination.download.$$" - local actual_sha256 - - if [[ -f "$destination" ]]; then - actual_sha256="$(file_sha256 "$destination")" - if [[ "$actual_sha256" == "$expected_sha256" ]]; then - return 0 - fi - rm -f -- "$destination" - fi - - rm -f -- "$temporary_path" - print -u2 -- "Downloading $description: $url" - if ! curl \ - --fail \ - --location \ - --retry 3 \ - --retry-all-errors \ - --connect-timeout 15 \ - --max-time 300 \ - --output "$temporary_path" \ - "$url"; then - rm -f -- "$temporary_path" - return 1 - fi - actual_sha256="$(file_sha256 "$temporary_path")" - if [[ "$actual_sha256" != "$expected_sha256" ]]; then - print -u2 -- "$description checksum mismatch: expected $expected_sha256, got $actual_sha256" - rm -f -- "$temporary_path" - return 1 - fi - mv -f -- "$temporary_path" "$destination" -} - -mkdir -p -- "$CACHE_DIR" -archive_path="$CACHE_DIR/intelephense-$archive_sha256.tgz" -download_verified_file "$archive_url" "$archive_sha256" "$archive_path" "Intelephense $archive_root" - -extraction_root="$(mktemp -d "$CACHE_DIR/extract.XXXXXX")" -trap 'rm -rf -- "$extraction_root"' EXIT -/usr/bin/tar -xzf "$archive_path" -C "$extraction_root" - -package_root="$extraction_root/$archive_root" -entrypoint_path="$package_root/$entrypoint" -license_file="$package_root/$license_path" -[[ -f "$entrypoint_path" ]] || { print -u2 -- "Intelephense entrypoint is missing: $entrypoint"; exit 1; } -[[ -f "$license_file" ]] || { print -u2 -- "Intelephense license is missing: $license_path"; exit 1; } - -rm -rf -- "$OUTPUT_DIR" -mkdir -p -- "$OUTPUT_DIR" -cp -R "$package_root" "$OUTPUT_DIR/package" -cp "$license_file" "$OUTPUT_DIR/LICENSE.txt" -cp "$MANIFEST" "$OUTPUT_DIR/language-server.json" - -mkdir -p -- "$OUTPUT_DIR/bin" -cat > "$OUTPUT_DIR/bin/intelephense" < { + await test("plugin packages and marketplace state stay isolated in each worktree", { timeout: 15000 }, () => { const listed = run(process.execPath, [reuseScript, "--list"]); assertSucceeded(listed); - for (const id of ["php-language-server-downloads", "official-plugin-packages", "plugin-marketplace-state"]) { + for (const id of ["official-plugin-packages", "plugin-marketplace-state"]) { assert.ok(!listed.stdout.includes(id)); const refused = reuse(["--resource", id]); assert.notEqual(refused.status, 0); diff --git a/scripts/verify-macos-package.sh b/scripts/verify-macos-package.sh index caa00c618b..571f789de3 100755 --- a/scripts/verify-macos-package.sh +++ b/scripts/verify-macos-package.sh @@ -246,9 +246,9 @@ if [[ -e "$app_path/Contents/Resources/LanguageServers" ]]; then exit 1 fi -# Go and PHP support are downloaded from the plugin marketplace, so the app -# ships no official plugin package; the marketplace index below is the offline -# catalog for them. +# Go and PHP support moved to community plugins in Lithe Store. The app must +# not carry their retired official packages; the marketplace index below is +# the offline catalog for downloadable official plugins. for optional_plugin in dev.lithe.plugin.php-support dev.lithe.plugin.go-support; do if [[ -e "$app_path/Contents/Resources/OfficialPlugins/$optional_plugin" ]]; then print -u2 -- "Optional plugin $optional_plugin must not be bundled with Lithe" diff --git a/scripts/verify-official-plugins.sh b/scripts/verify-official-plugins.sh index fb9c8c3da0..992a42eb3f 100755 --- a/scripts/verify-official-plugins.sh +++ b/scripts/verify-official-plugins.sh @@ -35,7 +35,7 @@ PLUGIN_ROOT=$(scripts/build-official-plugins.sh "${BUILD_ARGS[@]}") exit 1 } # A verification that sees zero packages must fail, not pass vacuously. -for official_plugin in dev.lithe.plugin.php-support dev.lithe.plugin.go-support dev.lithe.plugin.java-support; do +for official_plugin in dev.lithe.plugin.java-support; do [[ -d "$PLUGIN_ROOT/$official_plugin" ]] || { print -u2 -- "Official plugin package was not built: $official_plugin" exit 1 @@ -45,17 +45,6 @@ plugins=("$PLUGIN_ROOT"/*(/N)) package_signer_binary="" for plugin in "${plugins[@]}"; do swift run "${SWIFT_BUILD_ARGS[@]}" --skip-build LitheOfficialPluginVerifier "$plugin" - if [[ "$plugin:t" == "dev.lithe.plugin.php-support" ]]; then - [[ -f "$plugin/language-server.json" ]] || { - print -u2 -- "PHP plugin language-server manifest is missing: $plugin" - exit 1 - } - php_launcher="$plugin/language-server/bin/intelephense" - [[ -x "$php_launcher" ]] || { - print -u2 -- "PHP plugin Intelephense launcher is missing: $php_launcher" - exit 1 - } - fi # A package that ships inside the application is sealed by the app's code # signature instead of a publisher signature. signature_requirement=$(/usr/bin/plutil -extract vendor.signatureRequirement raw "$plugin/plugin.json") diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 00afa5d594..4b9ca9cf19 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -108,23 +108,13 @@ "reusable": false, "locations": [ ".build///OfficialPlugins", - "/Lithe/Plugins/dev.lithe.plugin.php-support/", + "/Lithe/Plugins//", "Lithe.app/Contents/Resources/OfficialPlugins/dev.lithe.plugin.java-support/", "/Lithe/PluginState//" ], - "identity": "Assembled plugin packages carry build-time downloads (Intelephense for PHP Support; 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", + "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", "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." }, - { - "id": "php-language-server-downloads", - "path": ".artifacts/php-language-server-downloads", - "reusable": false, - "locations": [ - ".artifacts/php-language-server-downloads" - ], - "identity": "The npm archive is pinned by language-server.json, but the extracted tool and build cache are mutable packaging state without a worktree publication stamp", - "reason": "Download and verify the PHP language server independently in each worktree; never copy a partially extracted archive or plugin-owned tool cache across worktrees." - }, { "id": "language-tools", "path": "/language-tools//", diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index bbaf4dd3e9..dc375476a3 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -286,8 +286,11 @@ contract must preserve these invariants: execution are built-in lifecycle modules. They are not marketplace plugins. - Java language tooling remains part of the built-in product. Every other language provider is represented by an independently configurable bundled - language-support plugin; PHP uses an optional separately installed native package, - Go uses the bundled signed native-package path while the + language-support plugin. On macOS, Go and PHP are community declarative + plugins from Lithe Store (`io.github.lithe-idea.go-support`, + `io.github.lithe-idea.php-support`); the retired official IDs + `dev.lithe.plugin.go-support` and `dev.lithe.plugin.php-support` keep their + pinned publisher signature requirement but have no official source. The remaining providers share the host's generic language-server module. - A downloadable language support package may declare language-server, execution, testing, and debug module IDs under one language ID. All referenced @@ -674,9 +677,11 @@ cannot disagree with a separate host-owned preparation veto. ### Optional PHP support -PHP plugin installation is explicit. The base application ships neither the PHP -native package nor Node/Bun/Intelephense. macOS accepts a verified signed package -through plugin management; its tools are user-owned and never deleted by Lithe. +PHP plugin installation is explicit. The base application ships neither a PHP +package nor Node/Bun/Intelephense. macOS installs the Lithe Store community package +`io.github.lithe-idea.php-support` through plugin management; the package does not +bundle Intelephense, which the user installs, and its tools are user-owned and +never deleted by Lithe. Windows installs language tools only on an explicit install/repair action; its managed tool cache is removed on uninstall without touching global tools. PHP run/test discovery and launch require enabled support. Disable cancels installation, diff --git a/shared/platform-feature-matrix/features/go-support-plugin.json b/shared/platform-feature-matrix/features/go-support-plugin.json index e543f0e5d8..b42171f6a1 100644 --- a/shared/platform-feature-matrix/features/go-support-plugin.json +++ b/shared/platform-feature-matrix/features/go-support-plugin.json @@ -2,13 +2,13 @@ "id": "go-support-plugin", "area": "语言支持", "group": "Go", - "capability": "Go 可下载声明式插件:gopls、运行与 go test", + "capability": "Go 社区插件:gopls、运行与 go test(由 Lithe Store 社区插件提供)", "macos": { "evidence": [ - "Plugins/mac/Official/GoSupport/plugin.json", - "Plugins/marketplace/official-plugins.json", + "macos/Tests/LitheTests/Fixtures/RetiredOfficialPlugins/GoSupport/plugin.json", "macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift", "macos/Sources/Lithe/Views/App/PluginManagementView.swift", + "macos/Sources/Lithe/Application/Features/PluginMarketplace.swift", "macos/Tests/LitheTests/LanguageExtensionProcessLifecycleTests.swift", "shared/contracts/plugins/v1/fixtures/command-plans.json" ], @@ -22,6 +22,6 @@ "implementationStatus": "missing", "verificationStatus": "not-applicable" }, - "owner": "Go Support", - "verification": "macOS 打开插件管理,在插件市场里找到官方的 Go Support 并下载安装(Lithe.app 本身不再内置它),确认安装后默认禁用、有重装和卸载按钮;启用后无需重启,打开含 go.mod 的项目并点开 .go 文件,确认使用用户安装的 gopls 启动语言服务器;运行 main.go、运行整套与单文件、单个测试函数的 go test;禁用后 gopls 和运行进程退出。未安装 gopls 时给出缺失提示而不是崩溃。Windows 暂无 Go 插件。" + "owner": "Go Support(社区插件 Lithe-IDEA/lithe-plugin-go)", + "verification": "macOS 打开插件管理,确认“Lithe 官方”源不再列出 Go Support,而 Lithe Store 社区源列出 io.github.lithe-idea.go-support 0.1.0;安装后启用无需重启,打开含 go.mod 的项目并点开 .go 文件,确认使用用户安装的 gopls 启动语言服务器;运行 main.go、运行整套与单文件、单个测试函数的 go test;禁用后 gopls 和运行进程退出。未安装 gopls 时给出缺失提示而不是崩溃。已安装旧官方包 dev.lithe.plugin.go-support 的环境:插件管理显示“已迁移到社区插件”提示、没有官方更新、可以卸载,旧包仍按官方签名要求校验;卸载后从市场安装社区版。Windows 暂无 Go 插件。" } diff --git a/shared/platform-feature-matrix/features/php-optional-plugin.json b/shared/platform-feature-matrix/features/php-optional-plugin.json index ea212c5798..c5e7e7d048 100644 --- a/shared/platform-feature-matrix/features/php-optional-plugin.json +++ b/shared/platform-feature-matrix/features/php-optional-plugin.json @@ -2,22 +2,17 @@ "id": "php-optional-plugin", "area": "语言支持", "group": "PHP", - "capability": "PHP 按需安装与插件生命周期", + "capability": "PHP 插件按需安装与生命周期(macOS 为 Lithe Store 社区插件)", "macos": { "evidence": [ - "Plugins/mac/Official/PhpSupport/plugin.json", - "Plugins/mac/Official/PhpSupport/language-server.json", - "scripts/official-plugin-distribution.mjs", + "macos/Tests/LitheTests/Fixtures/RetiredOfficialPlugins/PhpSupport/plugin.json", + "macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift", "macos/Sources/Lithe/Platform/MacOS/Plugins", "macos/Sources/Lithe/Views/App/PluginManagementView.swift", "macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", - ".github/workflows/release-macos.yml", - "macos/Sources/LithePluginPackageSigning/PluginPackageSignature.swift", - "macos/Tests/LitheTests/PluginPackageStoreTests.swift", - "macos/Tests/LitheTests/PluginLocalDevelopmentTests.swift", - "scripts/build-official-plugins.sh", - ".github/workflows/release-official-plugins.yml", - "Plugins/marketplace/official-plugins.json" + "macos/Sources/Lithe/Application/Features/PluginMarketplace.swift", + "macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift", + "macos/Tests/LitheTests/PluginPackageStoreTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -33,6 +28,6 @@ "implementationStatus": "implemented", "verificationStatus": "pending" }, - "owner": "PHP Support", - "verification": "macOS 在干净安装上打开插件管理,确认 PHP 出现在插件市场的“Lithe 官方”源中,可按本机架构下载 `plugin-php-support-v<版本>` 标签发布的签名包并安装、失败时可从磁盘导入;安装后重启,在 LSP 界面只控制当前项目开关和状态,验证重装、卸载均在重启后生效且用户工具保留。对官方 PHP 包将 plugin.json 的 signatureRequirement 从 publisherPackage 篡改为 sameTeamAsHost,确认 MacPluginPackageStore 在调用包 verifier 前按宿主 OfficialPluginCatalog 拒绝策略降级;删除或篡改 lithe-plugin-signature.json、manifest、版本或任意签名覆盖文件时也必须拒绝。Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。密钥重置后的 macOS 官方包使用 `lithe-official-plugins-v2` 信任根;验证必须确认旧 v1 签名被拒绝、新 v2 签名可安装,且新构建不会把旧客户端的更新兼容性误报为已验证。 macOS 本地开发包:用 `scripts/build-official-plugins.sh --configuration debug --plugin-id dev.lithe.plugin.php-support --development-unsigned` 在无私钥时生成不含签名文档的包,确认 debug 构建可从磁盘安装并标记为未签名开发包、同版本可重复安装,release 构建拒绝加载该来源,且带签名文档的包无论安装来源都必须验签;从 Dock 启动(精简 PATH)打开 PHP 项目并点开 `.php` 文件,确认 Intelephense 通过 `LITHE_NODE_PATH` 找到 Homebrew 或 nvm/volta/fnm 安装的 Node 并启动。 声明式插件包:PHP 插件只含 plugin.json 与打包的 Intelephense,不含原生代码;安装后启用、禁用无需重启。已安装旧版原生 PHP 包(0.3.0)的环境启动后,插件管理显示“需要更新”且不加载旧包代码,从插件市场安装或更新替换为声明式包并保留启用状态,回滚不能回到旧原生包。 发布:只有 PHP 插件目录变化并合入 preview 后,release-official-plugins 工作流才新建 `plugin-php-support-v<版本>` 标签与 Release,并自动开 PR 更新 Plugins/marketplace/index.json;版本未变而内容变化时 PR 检查失败。" + "owner": "PHP Support(macOS 社区插件 Lithe-IDEA/lithe-plugin-php)", + "verification": "macOS 在干净安装上打开插件管理,确认“Lithe 官方”源不再列出 PHP,Lithe Store 社区源列出 io.github.lithe-idea.php-support 0.1.0;安装后启用无需重启。社区包不打包 Intelephense(其许可证禁止再分发):未安装时 LSP 显示缺失提示而不是崩溃;`npm install -g intelephense` 后从 Dock 启动(精简 PATH)打开 PHP 项目并点开 `.php` 文件,确认 intelephense 通过 `LITHE_NODE_PATH` 找到 Homebrew 或 nvm/volta/fnm 安装的 Node 并启动。已安装旧官方包 dev.lithe.plugin.php-support 的环境:插件管理显示“已迁移到社区插件”提示、没有官方更新、可以卸载;对旧官方包将 plugin.json 的 signatureRequirement 从 publisherPackage 篡改为 sameTeamAsHost,确认 MacPluginPackageStore 仍按宿主 OfficialPluginCatalog 拒绝降级;旧包与社区包同时安装时,社区包因语言冲突被拒绝,需先卸载旧包。Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。" } diff --git a/shared/platform-feature-matrix/features/php-run-test.json b/shared/platform-feature-matrix/features/php-run-test.json index cb5dbba00b..d516ab34ab 100644 --- a/shared/platform-feature-matrix/features/php-run-test.json +++ b/shared/platform-feature-matrix/features/php-run-test.json @@ -5,7 +5,7 @@ "capability": "PHP 插件运行与 PHPUnit 测试", "macos": { "evidence": [ - "Plugins/mac/Official/PhpSupport/plugin.json", + "macos/Tests/LitheTests/Fixtures/RetiredOfficialPlugins/PhpSupport/plugin.json", "rust/lithe-core/src/plugins/declarative.rs", "shared/contracts/plugins/v1/fixtures/command-plans.json", "macos/Sources/LitheExecutionModule/Modules/BundledLanguageExecutionModule.swift", @@ -24,5 +24,5 @@ "verificationStatus": "pending" }, "owner": "PHP Support", - "verification": "macOS 运行 PHP 文件和单条/整套 PHPUnit(命令由声明式插件模板经 Rust Core 展开,与 shared/contracts/plugins/v1/fixtures/command-plans.json 一致);Windows 运行字符串/数组 Composer script 和整套 PHPUnit。禁用后菜单消失、运行进程退出,特殊文件名保持原样。Windows 暂不支持单方法发现。" + "verification": "macOS 安装 Lithe Store 社区 PHP 插件后运行 PHP 文件和单条/整套 PHPUnit(命令由声明式插件模板经 Rust Core 展开,与 shared/contracts/plugins/v1/fixtures/command-plans.json 一致);Windows 运行字符串/数组 Composer script 和整套 PHPUnit。禁用后菜单消失、运行进程退出,特殊文件名保持原样。Windows 暂不支持单方法发现。" } diff --git a/shared/platform-feature-matrix/features/plugin-marketplace.json b/shared/platform-feature-matrix/features/plugin-marketplace.json index 2780978dca..87948f4c51 100644 --- a/shared/platform-feature-matrix/features/plugin-marketplace.json +++ b/shared/platform-feature-matrix/features/plugin-marketplace.json @@ -47,5 +47,5 @@ }, "owner": "Extensions", "notes": "macOS 已在隔离实例实际验证社区 ZIP/目录选择与确认、本地来源和摘要、完整程序 JSON 展开、同版本再次确认及取消不改原记录、安装字节、重启扫描与 bundle 哈希不变;磁盘信任及受影响安装流程已运行定向回归,后台审查通过工作线程独占 Store 与文件管理器保持界面可取消,取消后等待读取结束再清理快照。页面关闭与迟到结果清理由状态回归覆盖,完整市场源管理、双语和全部磁盘组合尚未逐项实机验收,因此整体 verificationStatus 保持 pending。最终 Maven/Gradle 新清单还依赖待合入的共享契约和宿主能力,本次 preview 基线的受控包验收不替代最终插件整体验收。Windows 未实现本次声明式磁盘安装链,partial 不表示可以运行 macOS 流程。", - "verification": "macOS 打开插件管理,确认有“插件市场 / 已安装”两个标签和齿轮菜单;默认有“Lithe 官方”源(不能删除)和预置的社区源 Lithe Store(默认开启、可停用和删除,删除后重启不会再出现),Lithe Store 中能看到 Lua Support 并可安装,刷新后索引缓存写在 Caches 而 app bundle 不变。插件详情显示作者、来源仓库、标签、版本、许可证、发布日期和支持平台;索引里没有当前架构的包时显示“暂不支持 <平台> 平台”且不可安装,插件 API 版本不满足时显示不支持原因。在“管理插件仓库”中添加一个 GitHub 仓库地址,确认解析到仓库根目录的 lithe-plugin-index.json,可刷新、停用和删除;第三方源列出 dev.lithe.* 插件时该条目被忽略。安装社区插件时先校验 SHA-256(不匹配则不解压),超过下载上限的响应在下载中途被中止,解压后过大或文件过多的压缩包(含谎报大小的 ZIP)被拒绝且不留临时文件,再弹出确认框列出权限、要运行的程序和来源仓库,取消则不安装;已安装后改动插件目录内任意文件,重启后该插件拒绝加载;本地磁盘社区首次安装、更新、重装和同版本替换每次都必须明确确认,即使权限和程序未变;市场入口保持首次/缺少审批/出现 newPrograms 时确认的既有规则,无新程序的市场更新可沿用审批。确认框展示 Rust installReview 的全部权限与完整 canonical program 内容,包括参数、参数模板、验证探测、初始化选项、运行时与构建声明,已识别程序展开 Full Program Declaration 阅读完整 JSON,未知程序直接显示完整 JSON;同一插件出现在多个源时由用户选源,更新只走安装时记录的源。官方源只接受 Lithe 签名包。分别在英文和简体中文界面下检查列表、详情、安装确认框和仓库管理窗口,文字(含刷新时间格式)都跟随 Lithe 的语言设置,插件描述在有 localizedDescriptions 翻译时显示对应语言,否则显示默认英文描述;用辅助功能“按下”列表行只会选中,不会触发安装。索引里最新版本在本机不可用时,详情的版本、发布日期和平台显示实际会安装的版本,并单独注明最新版本不可用。磁盘验收:安装声明内置 Python 或其他已安装插件语言的社区包,重启拒绝冲突包后,原拥有者未设置、显式关闭或开启的偏好均保持原值;正常首次确认安装仍自动启用,更新保留选择。分别在市场下载和磁盘导入入口构造显式 ZIP 条目未超限但唯一隐式父目录超限的包,以及累计规范化路径 UTF-8 字节超限的包,确认在启动解压前拒绝并清理;显式目录与共享隐式父目录只计一次,恰好预算的合法包可解压。在隔离的用户配置与注入临时根下,从齿轮菜单分别选择一个合法社区 ZIP 与解包目录;准备后检查只生成本次 lithe-plugin-disk- 私有快照,ZIP 的 archiveSHA256 与快照 archive.zip 实测 SHA-256 相同,目录无归档摘要,界面显示本地文件名/内容摘要并明确未认证发布者或仓库。先取消,确认未安装且本次目录删除;再确认安装并重启,核对 installation.json 仅有 disk 来源、内容 digest 与完整 canonical approvedPrograms,没有伪造 marketplace 来源。分别导入程序未变的新版、相同版本原包及相同版本改过脚本的包,每次必须完整展示权限和程序并等待确认;取消不得改变已安装版本。确认框等待时更换原始 ZIP/目录,安装内容应仍等于准备快照;在受控测试中改动已准备快照后提交,应在 install staging 重验失败;安装后增加/删除/改动文件应在 scan 拒绝加载,改动上一版本后 rollback 应失败。正常升级再回滚时,当前/上一版本各自保留 origin、disk/marketplace 来源;同版本替换保留既有上一版本。刷新包含同 ID 的市场源,确认磁盘版本不出现可自动接管的更新/重装入口。以符号链接、Finder alias、特殊文件、多个 plugin.json、超过 64 层的目录、落在源目录内的临时根、中央目录/local header 路径类型冲突或重复条目的 ZIP、超限或损坏 ZIP、非法声明、冒用官方 ID/namespace 的包验证拒绝与清理;官方 publisher 签名、sameTeamAsHost 的 bundle 边界及仅 DEBUG 可用的 localDevelopment 包仍按原策略。导入和待确认期间关闭插件页,确认任务取消、已有快照立即清理、迟到的准备结果只清理而不会安装;成功、取消和失败后检查本次快照和 staging 无残留;在典型工作流前后比较发行 bundle 文件清单/哈希相同,关闭本次应用与子进程。静态及资源验证命令:node scripts/test-reuse-worktree-resources.mjs、./scripts/verify-agent-notes.sh、./scripts/verify-platform-feature-matrix.sh、./scripts/verify-runtime-bundle-immutability.sh;macOS 产品测试使用 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|PluginPackageStore|PluginManager',核对报告包含实际磁盘用例,未运行产品验收前保持 pending。Windows 当前仅共享索引契约、fixture 和 Rust Core 校验,无插件市场 UI 或此声明式 ZIP/目录快照审批流程,现有 worker-package 导入属于另一格式,实现状态保持 partial。" + "verification": "macOS 打开插件管理,确认有“插件市场 / 已安装”两个标签和齿轮菜单;默认有“Lithe 官方”源(不能删除)和预置的社区源 Lithe Store(默认开启、可停用和删除,删除后重启不会再出现),Lithe Store 中能看到 Lua、Go、PHP 三个社区语言插件并可安装,“Lithe 官方”源当前没有可下载插件(Go/PHP 已迁到 Lithe Store),刷新后索引缓存写在 Caches 而 app bundle 不变。插件详情显示作者、来源仓库、标签、版本、许可证、发布日期和支持平台;索引里没有当前架构的包时显示“暂不支持 <平台> 平台”且不可安装,插件 API 版本不满足时显示不支持原因。在“管理插件仓库”中添加一个 GitHub 仓库地址,确认解析到仓库根目录的 lithe-plugin-index.json,可刷新、停用和删除;第三方源列出 dev.lithe.* 插件时该条目被忽略。安装社区插件时先校验 SHA-256(不匹配则不解压),超过下载上限的响应在下载中途被中止,解压后过大或文件过多的压缩包(含谎报大小的 ZIP)被拒绝且不留临时文件,再弹出确认框列出权限、要运行的程序和来源仓库,取消则不安装;已安装后改动插件目录内任意文件,重启后该插件拒绝加载;本地磁盘社区首次安装、更新、重装和同版本替换每次都必须明确确认,即使权限和程序未变;市场入口保持首次/缺少审批/出现 newPrograms 时确认的既有规则,无新程序的市场更新可沿用审批。确认框展示 Rust installReview 的全部权限与完整 canonical program 内容,包括参数、参数模板、验证探测、初始化选项、运行时与构建声明,已识别程序展开 Full Program Declaration 阅读完整 JSON,未知程序直接显示完整 JSON;同一插件出现在多个源时由用户选源,更新只走安装时记录的源。官方源只接受 Lithe 签名包。分别在英文和简体中文界面下检查列表、详情、安装确认框和仓库管理窗口,文字(含刷新时间格式)都跟随 Lithe 的语言设置,插件描述在有 localizedDescriptions 翻译时显示对应语言,否则显示默认英文描述;用辅助功能“按下”列表行只会选中,不会触发安装。索引里最新版本在本机不可用时,详情的版本、发布日期和平台显示实际会安装的版本,并单独注明最新版本不可用。磁盘验收:安装声明内置 Python 或其他已安装插件语言的社区包,重启拒绝冲突包后,原拥有者未设置、显式关闭或开启的偏好均保持原值;正常首次确认安装仍自动启用,更新保留选择。分别在市场下载和磁盘导入入口构造显式 ZIP 条目未超限但唯一隐式父目录超限的包,以及累计规范化路径 UTF-8 字节超限的包,确认在启动解压前拒绝并清理;显式目录与共享隐式父目录只计一次,恰好预算的合法包可解压。在隔离的用户配置与注入临时根下,从齿轮菜单分别选择一个合法社区 ZIP 与解包目录;准备后检查只生成本次 lithe-plugin-disk- 私有快照,ZIP 的 archiveSHA256 与快照 archive.zip 实测 SHA-256 相同,目录无归档摘要,界面显示本地文件名/内容摘要并明确未认证发布者或仓库。先取消,确认未安装且本次目录删除;再确认安装并重启,核对 installation.json 仅有 disk 来源、内容 digest 与完整 canonical approvedPrograms,没有伪造 marketplace 来源。分别导入程序未变的新版、相同版本原包及相同版本改过脚本的包,每次必须完整展示权限和程序并等待确认;取消不得改变已安装版本。确认框等待时更换原始 ZIP/目录,安装内容应仍等于准备快照;在受控测试中改动已准备快照后提交,应在 install staging 重验失败;安装后增加/删除/改动文件应在 scan 拒绝加载,改动上一版本后 rollback 应失败。正常升级再回滚时,当前/上一版本各自保留 origin、disk/marketplace 来源;同版本替换保留既有上一版本。刷新包含同 ID 的市场源,确认磁盘版本不出现可自动接管的更新/重装入口。以符号链接、Finder alias、特殊文件、多个 plugin.json、超过 64 层的目录、落在源目录内的临时根、中央目录/local header 路径类型冲突或重复条目的 ZIP、超限或损坏 ZIP、非法声明、冒用官方 ID/namespace 的包验证拒绝与清理;官方 publisher 签名、sameTeamAsHost 的 bundle 边界及仅 DEBUG 可用的 localDevelopment 包仍按原策略。导入和待确认期间关闭插件页,确认任务取消、已有快照立即清理、迟到的准备结果只清理而不会安装;成功、取消和失败后检查本次快照和 staging 无残留;在典型工作流前后比较发行 bundle 文件清单/哈希相同,关闭本次应用与子进程。静态及资源验证命令:node scripts/test-reuse-worktree-resources.mjs、./scripts/verify-agent-notes.sh、./scripts/verify-platform-feature-matrix.sh、./scripts/verify-runtime-bundle-immutability.sh;macOS 产品测试使用 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|PluginPackageStore|PluginManager',核对报告包含实际磁盘用例,未运行产品验收前保持 pending。Windows 当前仅共享索引契约、fixture 和 Rust Core 校验,无插件市场 UI 或此声明式 ZIP/目录快照审批流程,现有 worker-package 导入属于另一格式,实现状态保持 partial。" } From 633d4c1085dcb540d7f05a5b16168c21d7221b6b Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 20 Oct 2026 10:49:37 +0800 Subject: [PATCH 2/2] fix(plugins): unblock Go/PHP community migration and add Restart Lithe - Uninstalling an enabled plugin no longer persists its modules as disabled, so the community replacement installs enabled; explicit "off" is kept. - Marketplace blocks installing the community Go/PHP package while the retired official package is still on disk and explains the order. - Add an IDEA-style "Restart Lithe" button (management banner, detail, marketplace, LSP control center) that quits through the normal confirmations and relaunches via a bounded detached helper. - LSP control center guides migrated languages to Lithe Store again. - Intelephense missing-tool guidance explains npm install and the licence. - Drop the stale LitheGoSupportModuleTests skip from macOS CI. --- .../2026-10-20-go-php-community-plugins.md | 13 +- .github/workflows/ci-macos.yml | 2 +- macos/Resources/en.lproj/Localizable.strings | 1 - .../zh-Hans.lproj/Localizable.strings | 9 +- .../Features/PluginManagement.swift | 4 + .../Features/PluginMarketplace.swift | 27 ++++ macos/Sources/Lithe/LitheApp.swift | 32 ++++- .../MacOS/MacApplicationRelauncher.swift | 58 +++++++++ .../MacOS/Plugins/MacPluginManager.swift | 19 ++- .../Runtime/MacRuntimeToolDiscovery.swift | 6 +- .../Storage/MacModuleConfigurationStore.swift | 7 + .../Views/App/ApplicationRestartAction.swift | 41 ++++++ .../Views/App/PluginManagementView.swift | 30 ++++- .../Views/App/PluginMarketplaceView.swift | 44 ++++++- .../Views/Language/LSPControlCenterView.swift | 48 ++++++- .../MacApplicationRelauncherTests.swift | 51 ++++++++ .../Tests/LitheTests/PluginManagerTests.swift | 122 ++++++++++++++++++ .../PluginMarketplaceFeatureModelTests.swift | 65 ++++++++-- .../features/go-support-plugin.json | 7 +- .../features/php-optional-plugin.json | 7 +- .../features/plugin-marketplace.json | 7 +- 21 files changed, 557 insertions(+), 43 deletions(-) create mode 100644 macos/Sources/Lithe/Platform/MacOS/MacApplicationRelauncher.swift create mode 100644 macos/Sources/Lithe/Views/App/ApplicationRestartAction.swift create mode 100644 macos/Tests/LitheTests/MacApplicationRelauncherTests.swift diff --git a/.agents/notes/implemented/architecture/2026-10-20-go-php-community-plugins.md b/.agents/notes/implemented/architecture/2026-10-20-go-php-community-plugins.md index 3ad378c587..2db1de2cbf 100644 --- a/.agents/notes/implemented/architecture/2026-10-20-go-php-community-plugins.md +++ b/.agents/notes/implemented/architecture/2026-10-20-go-php-community-plugins.md @@ -40,10 +40,16 @@ Go 和 PHP 已经是声明式插件:包里只有 `plugin.json`(PHP 另有 In ### 3. 用户手动迁移,不自动替换 -新旧包声明同一个语言(`go` / `php`)。启动时加载器按 ID 排序处理插件包,同一语言只能有一个拥有者:旧包 `dev.lithe.*` 排在前面,所以两者都装着时社区包会因为语言冲突被拒绝。迁移提示因此要求“先卸载旧包,再安装社区版”。 +新旧包声明同一个语言(`go` / `php`),也复用同样的语言模块 ID。同一语言只能有一个拥有者,而卸载要到下次启动才真正删除旧包:在那之前,旧包的模块仍然注册在当前进程里,社区包即使装上也会因语言冲突被拒绝。所以迁移顺序固定为“卸载旧包 → 重启 → 安装社区版 → 重启”: + +- 插件市场在旧包还在磁盘上时,把社区版显示为“需先卸载旧版”,不允许安装(`blockedByRetiredPlugin`);旧包已标记卸载时,直接给出“重启 Lithe”按钮。 +- 插件管理页顶部在本次会话有待重启生效的插件变更时显示横幅和“重启 Lithe”按钮,效果和 IntelliJ IDEA 的“Restart IDE”一样。重启走正常退出流程:未保存文件和运行中进程照常确认,任何一步取消都不会重启。真正重新打开应用的是一个分离的辅助进程,它等待旧进程退出(最多 30 秒),然后用 `open` 打开同一个 app bundle;如果旧进程到时还没退出,`open` 只会激活它,不会再开第二个 Lithe。 +- 卸载会停掉正在运行的模块,但这不等于用户关闭了它。卸载时只保留用户明确关闭过的模块(存储里是 `false`),其余模块的启用记录被清除,下一次确认安装(包括替代它的社区版)就会按“首次安装默认启用”的规则启用。否则旧版启用 → 卸载 → 安装社区版之后,社区版会继承卸载时写入的 `false`,装好了却是禁用的。 不做自动迁移,是因为社区包的信任模型不同(没有 Lithe 签名,需要用户在确认框里看过权限和程序),宿主不能替用户点这个确认。 +LSP 控制中心对已迁移的语言单独给出引导:没装插件时提示从 Lithe Store 安装,旧包还在时提示先卸载并重启,有待重启的变更时附带“重启 Lithe”按钮。 + ### 4. PHP 插件改用用户安装的 Intelephense 社区版 PHP 的语言服务器是 `launch.kind: systemCommand`,命令为 `intelephense --stdio`,并声明 `runtimeDependencies: node`。宿主照常发现 Node 并导出 `LITHE_NODE_PATH`,再把 Node 所在目录放到 PATH 最前面,这样从 Dock 启动时 npm 全局安装的 `intelephense` 脚本也能找到 `node`。没有安装时,LSP 状态显示缺少工具,而不是崩溃。 @@ -75,7 +81,7 @@ Go/PHP 插件可以独立迭代和发布,不占用主仓库 CI,也不需要 ## 验证 -- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginManager|PluginManagementPresentation|PluginPackageStore'`:已迁移 ID 不出现在官方列表、没有官方来源和更新、可以卸载,旧 ID 仍要求 publisher 签名。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginManager|PluginManagementPresentation|PluginPackageStore|ApplicationRelauncher'`:已迁移 ID 不出现在官方列表、没有官方来源和更新、可以卸载,旧 ID 仍要求 publisher 签名;旧包未删除前社区版不可安装;启用的旧包卸载、重启后安装替代包会启用,用户关闭过的保持关闭;重启辅助进程的等待有上限且不会多开实例。 - `node --test scripts/official-plugin-releases.test.mjs`:官方插件配置为空时发布计划为空,发布流程用合成插件继续可测。 - `node scripts/test-official-plugin-distribution.mjs`:应用内置名单只含 Java Support。 - `node scripts/test-reuse-worktree-resources.mjs`:已删除的 PHP 下载资源不再出现在复用清单里。 @@ -89,6 +95,9 @@ Go/PHP 插件可以独立迭代和发布,不占用主仓库 CI,也不需要 - `macos/Sources/Lithe/Application/Features/PluginMarketplace.swift` - `macos/Sources/Lithe/Views/App/PluginManagementView.swift` - `macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift` +- `macos/Sources/Lithe/Views/App/ApplicationRestartAction.swift` +- `macos/Sources/Lithe/Platform/MacOS/MacApplicationRelauncher.swift` +- `macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift` - `macos/Tests/LitheTests/Fixtures/RetiredOfficialPlugins/` - `Plugins/mac/README.md` - `Plugins/marketplace/` diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 1594967302..717f51c1e4 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -297,7 +297,7 @@ jobs: --skip GitStatusObservationTests \ --skip LitheGitPerformanceTests \ --skip '(LitheDatabaseModuleTests|LitheTests\.LitheCoreLogicTests/database)' \ - --skip '([Pp]lugin|LitheGoSupportModuleTests|LinuxDo)' + --skip '([Pp]lugin|LinuxDo)' - name: Run stable rollback disk image integration test if: needs.changes.outputs.swift == 'true' diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 63b8a761b2..9e57bb8aa7 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -37,7 +37,6 @@ "Uninstall" = "Uninstall"; "Reinstall" = "Reinstall"; "Restart required" = "Restart required"; -"Restart to Activate" = "Restart to Activate"; "No %@ project was detected in this workspace." = "No %@ project was detected in this workspace."; "Version %@ is installed. Restart Lithe to load it." = "Version %@ is installed. Restart Lithe to load it."; "Removed. Restart Lithe to finish." = "Removed. Restart Lithe to finish."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 5fb7af00f4..f092f01936 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1377,7 +1377,6 @@ "Uninstall" = "卸载"; "Reinstall" = "重新安装"; "Restart required" = "需要重启 Lithe"; -"Restart to Activate" = "重启后生效"; "Version %@ is installed. Restart Lithe to load it." = "已安装版本 %@,重启 Lithe 后生效。"; "Removed. Restart Lithe to finish." = "已移除,重启 Lithe 后完成。"; "Removed. Restore it to use it again." = "已移除,恢复后可再次使用。"; @@ -2852,7 +2851,13 @@ "Adds Java language intelligence through the Eclipse JDT language server, with Lombok, debugging, and test support. Ships its own Java runtime." = "通过 Eclipse JDT 语言服务器提供 Java 代码智能,并支持 Lombok、调试和测试。自带 Java 运行时。"; "Retired official Go plugin. Go support is now the community plugin Go Support in Lithe Store." = "已退役的官方 Go 插件。Go 支持现在由 Lithe Store 中的社区插件 Go Support 提供。"; -"%@ is no longer an official plugin. It moved to the community plugin %@ in Lithe Store and gets no further updates here. Uninstall it, then install the community version from the Marketplace." = "%@ 已不再是官方插件,已迁移为 Lithe Store 中的社区插件 %@,这里不会再收到更新。请先卸载它,再从插件市场安装社区版本。"; +"%@ is no longer an official plugin. It moved to the community plugin %@ in Lithe Store and gets no further updates here. Uninstall it, restart Lithe, then install the community version from the Marketplace." = "%@ 已不再是官方插件,已迁移为 Lithe Store 中的社区插件 %@,这里不会再收到更新。请先卸载它并重启 Lithe,再从插件市场安装社区版本。"; +"Restart Lithe to finish uninstalling %@, then install the community plugin %@ from the Marketplace." = "请重启 Lithe 以完成 %@ 的卸载,然后从插件市场安装社区插件 %@。"; +"Restart Lithe to finish uninstalling %@, then install this plugin." = "请重启 Lithe 以完成 %@ 的卸载,然后再安装此插件。"; +"Uninstall %@ from the Installed tab and restart Lithe first. It is the retired official version of this plugin and supports the same language." = "请先在“已安装”中卸载 %@ 并重启 Lithe。它是此插件已退役的官方版本,支持同一种语言。"; +"Replaces an Installed Plugin" = "需先卸载旧版"; +"Plugin changes take effect after Lithe restarts." = "插件变更将在重启 Lithe 后生效。"; +"Restart Lithe" = "重启 Lithe"; "Download PHP Support from the official signed plugin package." = "从官方签名的插件包下载 PHP 支持。"; diff --git a/macos/Sources/Lithe/Application/Features/PluginManagement.swift b/macos/Sources/Lithe/Application/Features/PluginManagement.swift index ee73457992..92440c571c 100644 --- a/macos/Sources/Lithe/Application/Features/PluginManagement.swift +++ b/macos/Sources/Lithe/Application/Features/PluginManagement.swift @@ -35,6 +35,10 @@ struct PluginManagementSnapshot: Equatable, Sendable, Identifiable { /// Options the plugin lets the user change; empty when it declares none. var settings: [PluginSettingState] = [] var disk: PluginDiskProvenance? = nil + /// Something changed in this session (install, update, uninstall, …) that + /// only a restart applies. Unlike `requiresRestart`, a bundled plugin the + /// user removed in an earlier session does not count. + var hasChangesPendingRestart = false var id: PluginID { manifest.id } } diff --git a/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift b/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift index 39710b82e3..d1bd5a9f9f 100644 --- a/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift +++ b/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift @@ -130,6 +130,10 @@ enum PluginMarketplaceInstallState: Equatable { /// Built in or installed from disk, not managed by the marketplace. case managedOutsideMarketplace(version: String) case unsupported(message: String) + /// The community plugin replaces a retired official plugin that is still + /// installed, or still loaded until restart after its uninstall. Both + /// declare the same language, so the community package would be rejected. + case blockedByRetiredPlugin(name: String, uninstallPending: Bool) } /// All sources' listings of one plugin ID. @@ -389,6 +393,13 @@ final class PluginMarketplaceFeatureModel: ObservableObject { } return .installed(version: installedVersion) } + if let retired = retiredPluginBlocking(pluginID) { + return .blockedByRetiredPlugin( + name: retired.manifest.displayName, + uninstallPending: retired.installationStatus == .uninstallPending + || retired.installationStatus == .removed + ) + } switch offer.entry.availability { case .available: return .installable @@ -435,6 +446,21 @@ final class PluginMarketplaceFeatureModel: ObservableObject { return nil } + /// The retired official plugin that `pluginID` replaces, while its package + /// is still on disk. An uninstall is only finished by the next launch. + func retiredPluginBlocking(_ pluginID: PluginID) -> PluginManagementSnapshot? { + guard let retired = OfficialPluginCatalog.entries.first(where: { $0.communityReplacementID == pluginID }) else { + return nil + } + return pluginManager.snapshots.first { $0.id == retired.id } + } + + /// Whether a plugin change made in this session waits for a restart, so + /// Plugin Management offers "Restart Lithe". + var hasChangesPendingRestart: Bool { + pluginManager.snapshots.contains(where: \.hasChangesPendingRestart) + } + /// An installed or updated plugin is only loaded when Lithe starts, so the /// marketplace says so instead of looking finished. func requiresRestart(_ pluginID: PluginID) -> Bool { @@ -459,6 +485,7 @@ final class PluginMarketplaceFeatureModel: ObservableObject { func install(_ offer: PluginMarketplaceOffer) async { let pluginID = PluginID(offer.entry.id) guard !busyPluginIDs.contains(pluginID), + retiredPluginBlocking(pluginID) == nil, let download = offer.entry.availability.download else { return } busyPluginIDs.insert(pluginID) errors[pluginID] = nil diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index e4184814cf..67ced2f817 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -46,6 +46,7 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate { private var terminationCleanupTask: Task? private var terminationCleanupState: TerminationCleanupState = .idle private var isUpdateInstallTermination = false + private var relaunchesAfterTermination = false private var updateForceExitTask: Task? weak var projectSessions: ProjectSessionManager? { didSet { @@ -134,6 +135,16 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate { isUpdateInstallTermination = true } + /// "Restart Lithe": quits through the normal confirmations and opens Lithe + /// again once this process has exited. Cancelling any confirmation keeps + /// Lithe running and forgets the request. + func requestRestart() { + guard terminationCleanupState == .idle, terminationConfirmationTask == nil, + !isUpdateInstallTermination else { return } + relaunchesAfterTermination = true + NSApp.terminate(nil) + } + func finishUpdateCycle() { if terminationCleanupState == .idle { isUpdateInstallTermination = false @@ -155,7 +166,11 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { guard let projectSessions else { - return (prepareStableRollbackTermination?() ?? true) ? .terminateNow : .terminateCancel + guard prepareStableRollbackTermination?() ?? true else { + relaunchesAfterTermination = false + return .terminateCancel + } + return .terminateNow } // AppKit may ask more than once while a previous asynchronous reply is @@ -179,6 +194,7 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate { self.isUpdateInstallTermination = false } self.terminationConfirmationTask = nil + self.relaunchesAfterTermination = false sender.reply(toApplicationShouldTerminate: false) return } @@ -197,6 +213,7 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate { self.isUpdateInstallTermination = false } self.cancelProcessExit() + self.relaunchesAfterTermination = false sender.reply(toApplicationShouldTerminate: false) return } @@ -204,6 +221,7 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate { guard self.prepareStableRollbackTermination?() ?? true else { self.isUpdateInstallTermination = false self.cancelProcessExit() + self.relaunchesAfterTermination = false sender.reply(toApplicationShouldTerminate: false) return } @@ -269,6 +287,9 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate { // is still alive must not outlive the IDE. MacLiveProcessGroups.application.terminateAllAndWait() sweepProcessScope() + if relaunchesAfterTermination, !isUpdateInstallTermination { + MacApplicationRelauncher.scheduleRelaunch() + } isUpdateInstallTermination = false updateForceExitTask?.cancel() updateForceExitTask = nil @@ -596,6 +617,11 @@ struct LitheApp: App { private var model: AppModel { projectSessions.activeModel } + private var restartApplication: ApplicationRestartAction { + let appDelegate = appDelegate + return ApplicationRestartAction { appDelegate.requestRestart() } + } + var body: some Scene { WindowGroup(id: LitheWindowID.welcome) { RootView(scope: .primary) @@ -607,6 +633,7 @@ struct LitheApp: App { .environmentObject(frameRateMonitor) .environmentObject(updateChecker) .environment(\.locale, settings.language.locale) + .environment(\.restartApplication, restartApplication) .font(LitheTheme.uiFont(.body)) // SwiftUI does not consistently re-resolve every existing // LocalizedStringKey when only the locale environment value @@ -803,6 +830,7 @@ struct LitheApp: App { .environmentObject(frameRateMonitor) .environmentObject(updateChecker) .environment(\.locale, settings.language.locale) + .environment(\.restartApplication, restartApplication) .font(LitheTheme.uiFont(.body)) .id("\(windowID.uuidString)-\(settings.language)") .preferredColorScheme(settings.themePreference.preferredColorScheme) @@ -821,6 +849,7 @@ struct LitheApp: App { .environmentObject(settings) .environmentObject(updateChecker) .environment(\.locale, settings.language.locale) + .environment(\.restartApplication, restartApplication) .font(LitheTheme.uiFont(.body)) } .defaultSize(width: 900, height: 668) @@ -832,6 +861,7 @@ struct LitheApp: App { .environmentObject(model) .environmentObject(updateChecker) .environment(\.locale, settings.language.locale) + .environment(\.restartApplication, restartApplication) .font(LitheTheme.uiFont(.body)) .id(settings.language) .preferredColorScheme(settings.themePreference.preferredColorScheme) diff --git a/macos/Sources/Lithe/Platform/MacOS/MacApplicationRelauncher.swift b/macos/Sources/Lithe/Platform/MacOS/MacApplicationRelauncher.swift new file mode 100644 index 0000000000..2b8baa8ad6 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/MacApplicationRelauncher.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Opens Lithe again after this process exits, for "Restart Lithe" after +/// plugin changes. The request goes through the normal quit flow first, so +/// unsaved documents and running processes are confirmed as on any quit. +enum MacApplicationRelauncher { + struct Command: Equatable { + let executableURL: URL + let arguments: [String] + } + + /// Waits at most this long for the old process to exit. If it is still + /// running, `open` only activates it instead of starting a second copy. + static let exitWaitTenths = 300 + + /// The helper polls the old process and then opens the app bundle, or + /// starts the executable directly for an unbundled development build. + static func command(bundleURL: URL, executableURL: URL?, processID: Int32) -> Command? { + let target: String + let mode: String + if bundleURL.pathExtension == "app" { + target = bundleURL.path + mode = "bundle" + } else if let executableURL { + target = executableURL.path + mode = "executable" + } else { + return nil + } + let script = """ + pid="$1"; target="$2"; mode="$3"; limit="$4"; i=0 + while kill -0 "$pid" 2>/dev/null && [ "$i" -lt "$limit" ]; do sleep 0.1; i=$((i + 1)); done + if [ "$mode" = bundle ]; then exec /usr/bin/open "$target"; fi + "$target" /dev/null 2>&1 & + """ + return Command( + executableURL: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", script, "lithe-relaunch", "\(processID)", target, mode, "\(exitWaitTenths)"] + ) + } + + /// Call as the last step of termination, after child processes have been + /// ended, so the helper is not one of the processes Lithe cleans up. + static func scheduleRelaunch(bundle: Bundle = .main) { + guard let command = command( + bundleURL: bundle.bundleURL, + executableURL: bundle.executableURL, + processID: ProcessInfo.processInfo.processIdentifier + ) else { return } + let process = Process() + process.executableURL = command.executableURL + process.arguments = command.arguments + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try? process.run() + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift index a272a192d3..8d98241674 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift @@ -209,12 +209,21 @@ final class MacPluginManager: PluginManaging { )) return } - if registeredPluginIDs.contains(pluginID) { - try await setEnabled(false, for: pluginID) - } guard !installed.manifest.modules.contains(where: { $0.manifest.isRequired }) else { throw PluginManagerError.requiredPluginCannotBeUninstalled(pluginID) } + if registeredPluginIDs.contains(pluginID) { + // Stopping the modules is not the user turning them off. Language + // module IDs outlive the package (a later install, or the community + // plugin that replaces a retired official one, reuses them), so keep + // only an explicit "off" and let the next installation start fresh. + let moduleIDs = installed.manifest.modules.map(\.manifest.id) + let explicitlyDisabled = Set(moduleIDs.filter { configurationStore.enabledState(for: $0) == false }) + try await setEnabled(false, for: pluginID) + for moduleID in moduleIDs where !explicitlyDisabled.contains(moduleID) { + configurationStore.clearEnabledState(for: moduleID) + } + } beforePackageReplacement(pluginID) if installed.installation.origin == .bundled { // The package itself lives in the read-only app bundle. @@ -347,7 +356,9 @@ final class MacPluginManager: PluginManaging { statusMessage: statusMessage, marketplace: marketplace, settings: settingStates(declaredSettings, pluginID: manifest.id), - disk: disk + disk: disk, + hasChangesPendingRestart: restartRequiredPluginIDs.contains(manifest.id) + || installationStatus == .uninstallPending ) } diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift index e6a3efea9a..104bcfb3d6 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift @@ -181,8 +181,10 @@ struct MacRuntimeToolDiscovery: RuntimeToolDiscovery { return RuntimeToolGuidance( command: command, displayName: "PHP language server", - summary: "Intelephense was not found in the PHP Support plugin or configured toolchain.", - recovery: "Reinstall PHP Support from Plugin Management, or install Node.js and configure an Intelephense executable." + summary: "Intelephense was not found on PATH or in the configured toolchain.", + // PHP Support does not bundle Intelephense: its licence does not + // allow redistribution, so the user installs it. + recovery: "Install Node.js, then run npm install -g intelephense and restart the language server." ) default: return RuntimeToolGuidance( diff --git a/macos/Sources/Lithe/Platform/MacOS/Storage/MacModuleConfigurationStore.swift b/macos/Sources/Lithe/Platform/MacOS/Storage/MacModuleConfigurationStore.swift index 423948ad89..a08747041a 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Storage/MacModuleConfigurationStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Storage/MacModuleConfigurationStore.swift @@ -19,6 +19,13 @@ final class MacModuleConfigurationStore: ModuleConfigurationStore, ModuleRecover store.set(enabled, forKey: key(for: moduleID)) } + /// Forgets the stored choice so the module's default (or a confirmed + /// first installation) decides again. + func clearEnabledState(for moduleID: ModuleID) { + lock.lock(); defer { lock.unlock() } + store.set(nil, forKey: key(for: moduleID)) + } + func pendingActivation() -> ModuleID? { lock.lock(); defer { lock.unlock() } return pendingActivationsLocked().first diff --git a/macos/Sources/Lithe/Views/App/ApplicationRestartAction.swift b/macos/Sources/Lithe/Views/App/ApplicationRestartAction.swift new file mode 100644 index 0000000000..85733d682d --- /dev/null +++ b/macos/Sources/Lithe/Views/App/ApplicationRestartAction.swift @@ -0,0 +1,41 @@ +import SwiftUI + +/// Restarts Lithe through the normal quit confirmations, like IntelliJ IDEA's +/// "Restart IDE" after plugin changes. The app scene injects it; previews and +/// tests leave it nil and the restart button is hidden. +struct ApplicationRestartAction { + let perform: @MainActor () -> Void + + @MainActor func callAsFunction() { perform() } +} + +private struct ApplicationRestartActionKey: EnvironmentKey { + static let defaultValue: ApplicationRestartAction? = nil +} + +extension EnvironmentValues { + var restartApplication: ApplicationRestartAction? { + get { self[ApplicationRestartActionKey.self] } + set { self[ApplicationRestartActionKey.self] = newValue } + } +} + +/// "Restart Lithe" button shown wherever a plugin change waits for a restart. +struct RestartLitheButton: View { + @Environment(\.restartApplication) private var restartApplication + var prominent = true + + var body: some View { + if let restartApplication { + if prominent { + Button(LocalizedStringKey("Restart Lithe")) { restartApplication() } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } else { + Button(LocalizedStringKey("Restart Lithe")) { restartApplication() } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } +} diff --git a/macos/Sources/Lithe/Views/App/PluginManagementView.swift b/macos/Sources/Lithe/Views/App/PluginManagementView.swift index dc27acb2df..7d58cfdc4d 100644 --- a/macos/Sources/Lithe/Views/App/PluginManagementView.swift +++ b/macos/Sources/Lithe/Views/App/PluginManagementView.swift @@ -39,6 +39,10 @@ private struct PluginManagementPage: View { VStack(spacing: 0) { header Rectangle().fill(LitheTheme.divider).frame(height: 1) + if feature.hasChangesPendingRestart { + restartBanner + Rectangle().fill(LitheTheme.divider).frame(height: 1) + } if let error = feature.diskInstallError { Label(error, systemImage: "exclamationmark.triangle.fill") .font(LitheTheme.smallFont) @@ -111,6 +115,21 @@ private struct PluginManagementPage: View { .background(LitheTheme.settingsSurface) } + /// Like IntelliJ IDEA's "Restart IDE": plugin installs, updates, and + /// uninstalls apply at the next launch, so offer it right here. + private var restartBanner: some View { + HStack(spacing: 10) { + Label(LocalizedStringKey("Plugin changes take effect after Lithe restarts."), systemImage: "arrow.clockwise.circle") + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.warning) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 8) + RestartLitheButton() + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + private func tabButton(_ value: Tab, title: String) -> some View { Button { tab = value @@ -405,6 +424,9 @@ private struct InstalledPluginsView: View { .buttonStyle(.bordered) .disabled(isApplyingChanges || isBusy || plugin.isRequired) } + if plugin.hasChangesPendingRestart { + RestartLitheButton(prominent: false) + } if isBusy { ProgressView().controlSize(.small) } @@ -452,10 +474,16 @@ private struct InstalledPluginsView: View { .font(LitheTheme.smallFont) } if let replacementID = OfficialPluginCatalog.entry(for: plugin.id)?.communityReplacementID { + // The old package keeps its language until the next launch + // removes it, so the community version is installed after + // that restart, not beside the old one. + let uninstalling = plugin.installationStatus == .uninstallPending Label( String( format: PluginMarketplacePresentation.localized( - "%@ is no longer an official plugin. It moved to the community plugin %@ in Lithe Store and gets no further updates here. Uninstall it, then install the community version from the Marketplace.", + uninstalling + ? "Restart Lithe to finish uninstalling %@, then install the community plugin %@ from the Marketplace." + : "%@ is no longer an official plugin. It moved to the community plugin %@ in Lithe Store and gets no further updates here. Uninstall it, restart Lithe, then install the community version from the Marketplace.", locale: locale ), plugin.manifest.displayName, diff --git a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift index 6688efdd9a..96abc191c7 100644 --- a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift +++ b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift @@ -168,9 +168,7 @@ struct PluginMarketplaceView: View { .controlSize(.small) case .installed, .installedFromOtherSource, .managedOutsideMarketplace: if feature.requiresRestart(pluginID) { - Text(LocalizedStringKey("Restart to Activate")) - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.warning) + RestartLitheButton(prominent: false) } else { Text(LocalizedStringKey("Installed")) .font(LitheTheme.smallFont) @@ -180,6 +178,14 @@ struct PluginMarketplaceView: View { Text(LocalizedStringKey("Not Supported")) .font(LitheTheme.smallFont) .foregroundStyle(LitheTheme.secondaryText) + case .blockedByRetiredPlugin(_, let uninstallPending): + if uninstallPending { + RestartLitheButton(prominent: false) + } else { + Text(LocalizedStringKey("Replaces an Installed Plugin")) + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.warning) + } } } } @@ -321,9 +327,13 @@ private struct PluginMarketplaceDetail: View { .tint(LitheTheme.accent) case .installed, .installedFromOtherSource, .managedOutsideMarketplace: HStack(spacing: 8) { - Button(LocalizedStringKey("Installed")) {} - .buttonStyle(.bordered) - .disabled(true) + if feature.requiresRestart(PluginID(entry.id)) { + RestartLitheButton() + } else { + Button(LocalizedStringKey("Installed")) {} + .buttonStyle(.bordered) + .disabled(true) + } if feature.canUninstall(PluginID(entry.id)) { Button(LocalizedStringKey("Uninstall")) { confirmingUninstall = true } .buttonStyle(.bordered) @@ -343,6 +353,13 @@ private struct PluginMarketplaceDetail: View { Button(LocalizedStringKey("Not Supported")) {} .buttonStyle(.bordered) .disabled(true) + case .blockedByRetiredPlugin(_, let uninstallPending): + HStack(spacing: 8) { + Button(LocalizedStringKey("Install")) {} + .buttonStyle(.bordered) + .disabled(true) + if uninstallPending { RestartLitheButton() } + } } } } @@ -384,6 +401,21 @@ private struct PluginMarketplaceDetail: View { @ViewBuilder private var stateMessage: some View { switch state { + case .blockedByRetiredPlugin(let name, let uninstallPending): + Label( + String( + format: PluginMarketplacePresentation.localized( + uninstallPending + ? "Restart Lithe to finish uninstalling %@, then install this plugin." + : "Uninstall %@ from the Installed tab and restart Lithe first. It is the retired official version of this plugin and supports the same language.", + locale: locale + ), + name + ), + systemImage: "arrow.triangle.branch" + ) + .foregroundStyle(LitheTheme.warning) + .fixedSize(horizontal: false, vertical: true) case .unsupported(let message): Label( PluginMarketplacePresentation.unsupportedMessage(entry: entry, platform: feature.platform, fallback: message, locale: locale), diff --git a/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift b/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift index feb7b40331..66b260320e 100644 --- a/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift +++ b/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift @@ -162,12 +162,47 @@ struct LSPControlCenterView: View { } private var pluginNotices: [String] { - OfficialPluginCatalog.publishedEntries.compactMap { entry in - guard projectUses(entry), !pluginIsReady(entry) else { return nil } + OfficialPluginCatalog.entries.compactMap { entry in + guard projectUses(entry) else { return nil } + if let replacementID = entry.communityReplacementID { + return migratedPluginNotice(entry, replacementID: replacementID) + } + guard !pluginIsReady(entry) else { return nil } return pluginNotice(entry) } } + /// Go and PHP moved to Lithe Store: point at the community plugin, and at + /// the uninstall-and-restart step while the retired official one remains. + private func migratedPluginNotice(_ entry: OfficialPluginCatalog.Entry, replacementID: PluginID) -> String? { + let name = entry.displayName + let language = entry.languageDisplayName + if let retired = pluginSnapshot(entry) { + if retired.installationStatus == .uninstallPending { + return usesChinese + ? "重启 Lithe 以完成旧版 \(name) 的卸载,然后在插件市场的 Lithe Store 中安装社区版 \(name)。" + : "Restart Lithe to finish uninstalling the old \(name), then install the community \(name) from Lithe Store in the Marketplace." + } + return usesChinese + ? "\(name) 已迁移为 Lithe Store 社区插件,旧官方版不再更新。请在插件管理中卸载旧版并重启 Lithe,再安装社区版。" + : "\(name) moved to Lithe Store as a community plugin, and the old official version gets no updates. Uninstall it from Plugin Management, restart Lithe, then install the community version." + } + guard let community = model.pluginSnapshots.first(where: { $0.id == replacementID }) else { + return usesChinese + ? "检测到 \(language) 项目,请在插件管理的插件市场中,从 Lithe Store 安装 \(name)。" + : "This \(language) project needs \(name). Install it from Lithe Store in the Plugin Management Marketplace." + } + if community.requiresRestart { + return usesChinese + ? "\(name) 已安装,重启 Lithe 后才能在这里控制语言服务器。" + : "\(name) is installed. Restart Lithe before controlling its language server here." + } + guard !community.isEnabled else { return nil } + return usesChinese + ? "\(name) 插件已禁用,请先在插件管理中启用。" + : "\(name) is disabled. Enable it from Plugin Management first." + } + private func pluginNotice(_ entry: OfficialPluginCatalog.Entry) -> String { let name = entry.displayName let language = entry.languageDisplayName @@ -199,8 +234,13 @@ struct LSPControlCenterView: View { private func phpPluginNotice(_ message: String) -> some View { LitheBanner(status: .info) { Text(message).fixedSize(horizontal: false, vertical: true) - Button(usesChinese ? "打开插件管理" : "Open Plugin Management") { - model.showSettings(category: .plugins) + HStack(spacing: 8) { + Button(usesChinese ? "打开插件管理" : "Open Plugin Management") { + model.showSettings(category: .plugins) + } + if model.pluginSnapshots.contains(where: \.hasChangesPendingRestart) { + RestartLitheButton(prominent: false) + } } } } diff --git a/macos/Tests/LitheTests/MacApplicationRelauncherTests.swift b/macos/Tests/LitheTests/MacApplicationRelauncherTests.swift new file mode 100644 index 0000000000..938c34d96c --- /dev/null +++ b/macos/Tests/LitheTests/MacApplicationRelauncherTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing +@testable import Lithe + +/// "Restart Lithe" hands the relaunch to a helper that outlives this process. +/// Only the command is checked here; running it would start another Lithe. +@Suite("Application relauncher") +struct MacApplicationRelauncherTests { + @Test + func bundledAppIsOpenedAgainAfterTheProcessExits() throws { + let command = try #require(MacApplicationRelauncher.command( + bundleURL: URL(fileURLWithPath: "/Applications/Lithe Preview.app"), + executableURL: URL(fileURLWithPath: "/Applications/Lithe Preview.app/Contents/MacOS/Lithe"), + processID: 4242 + )) + + #expect(command.executableURL.path == "/bin/sh") + // Paths are passed as arguments, never spliced into the script. + #expect(Array(command.arguments.dropFirst(2)) == [ + "lithe-relaunch", "4242", "/Applications/Lithe Preview.app", "bundle", + "\(MacApplicationRelauncher.exitWaitTenths)", + ]) + let script = command.arguments[1] + #expect(!script.contains("Lithe Preview")) + // The wait is bounded, and a still-running old instance is only + // activated by `open` rather than duplicated with `open -n`. + #expect(script.contains("[ \"$i\" -lt \"$limit\" ]")) + #expect(script.contains("exec /usr/bin/open \"$target\"")) + #expect(!script.contains("open -n")) + } + + @Test + func unbundledDevelopmentBuildStartsItsExecutable() throws { + let command = try #require(MacApplicationRelauncher.command( + bundleURL: URL(fileURLWithPath: "/tmp/build/debug"), + executableURL: URL(fileURLWithPath: "/tmp/build/debug/Lithe"), + processID: 7 + )) + + #expect(Array(command.arguments.suffix(3)) == ["/tmp/build/debug/Lithe", "executable", "\(MacApplicationRelauncher.exitWaitTenths)"]) + } + + @Test + func nothingToRelaunchWithoutAnExecutable() { + #expect(MacApplicationRelauncher.command( + bundleURL: URL(fileURLWithPath: "/tmp/build/debug"), + executableURL: nil, + processID: 7 + ) == nil) + } +} diff --git a/macos/Tests/LitheTests/PluginManagerTests.swift b/macos/Tests/LitheTests/PluginManagerTests.swift index 5f285a7ae5..fa4659031c 100644 --- a/macos/Tests/LitheTests/PluginManagerTests.swift +++ b/macos/Tests/LitheTests/PluginManagerTests.swift @@ -281,6 +281,128 @@ struct PluginManagerTests { #expect(enabled.requiresRestart) } + /// Migration from a retired official plugin to its community replacement: + /// both declare the same language module. Uninstalling the enabled old + /// package stops the module, but that must not count as the user turning + /// it off, or the replacement would install disabled. + @Test + func uninstallingAnEnabledPluginLetsItsReplacementStartEnabled() async throws { + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let configuration = MacModuleConfigurationStore(store: PluginManagerKeyValueStore()) + configuration.setEnabledState(true, for: pluginModuleID) + + try await uninstallRegisteredPackage(root: root, configuration: configuration) + #expect(configuration.enabledState(for: pluginModuleID) == nil) + + let replacement = try installReplacementAfterRestart(root: root, configuration: configuration) + #expect(configuration.enabledState(for: pluginModuleID) == true) + #expect(replacement.isEnabled) + #expect(replacement.requiresRestart) + } + + @Test + func uninstallKeepsAnExplicitlyDisabledModuleOff() async throws { + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let configuration = MacModuleConfigurationStore(store: PluginManagerKeyValueStore()) + configuration.setEnabledState(true, for: pluginModuleID) + + try await uninstallRegisteredPackage(root: root, configuration: configuration, disableFirst: true) + #expect(configuration.enabledState(for: pluginModuleID) == false) + + let replacement = try installReplacementAfterRestart(root: root, configuration: configuration) + #expect(configuration.enabledState(for: pluginModuleID) == false) + #expect(!replacement.isEnabled) + } + + private func lifecyclePackage(root: URL, id: String) throws -> URL { + try DeclarativePluginTestSupport.writePackage( + at: root.appendingPathComponent("source-\(id)"), + manifest: DeclarativePluginTestSupport.manifestObject( + id: id, + version: PluginVersion(major: 0, minor: 3, patch: 0), + signatureRequirement: .publisherPackage + ) + ) + } + + private func lifecycleStore(root: URL) -> MacPluginPackageStore { + MacPluginPackageStore( + rootURL: root.appendingPathComponent("installed"), + validator: DeclarativePluginTestSupport.operations, + verifier: MacOfficialPluginSignatureVerifier(allowsLocalDevelopmentPackages: true) + ) + } + + /// The old package is installed, registered, and running, then the user + /// (optionally after disabling it) uninstalls it. + private func uninstallRegisteredPackage( + root: URL, + configuration: MacModuleConfigurationStore, + disableFirst: Bool = false + ) async throws { + let store = lifecycleStore(root: root) + let old = try store.installPackage( + from: lifecyclePackage(root: root, id: "dev.example.retired"), + origin: .localDevelopment + ) + let runtime = ModuleRuntime(configurationStore: configuration, recoveryStore: configuration) + let moduleManifest = try #require(old.manifest.modules.first?.manifest) + #expect(moduleManifest.id == pluginModuleID) + let workspace = try #require(BuiltInModuleCatalog.manifest(for: .workspace)) + try runtime.register(ModuleFactory(manifest: workspace) { PluginManagerWorkspaceModule(manifest: workspace) }) + let recorder = PluginManagerModuleRecorder() + try runtime.register(ModuleFactory(manifest: moduleManifest) { + PluginManagerTestModule(manifest: moduleManifest, recorder: recorder) + }) + _ = try await runtime.activate(pluginModuleID) + let manager = MacPluginManager( + packageStore: store, + moduleRuntime: runtime, + configurationStore: configuration, + settings: InMemoryPluginSettings(), + launchMode: .normal, + startup: MacPluginStartupResult(installedPlugins: [old], registeredPlugins: [old], issues: []), + allowsLocalDevelopmentPackages: true + ) + if disableFirst { try await manager.setEnabled(false, for: old.manifest.id) } + + try await manager.uninstall(old.manifest.id) + + #expect(recorder.shutdownCount == 1) + #expect(try runtime.snapshot(for: pluginModuleID).state == .disabled) + let pending = try #require(manager.snapshots.first { $0.id == old.manifest.id }) + #expect(pending.installationStatus == .uninstallPending) + #expect(pending.hasChangesPendingRestart) + } + + /// Restart (the old package is removed at launch), then install the + /// replacement that declares the same language module. + private func installReplacementAfterRestart( + root: URL, + configuration: MacModuleConfigurationStore + ) throws -> PluginManagementSnapshot { + let store = lifecycleStore(root: root) + try store.prepareForLaunch() + let scan = try store.scanInstalledPlugins() + #expect(scan.packages.isEmpty) + let manager = MacPluginManager( + packageStore: store, + moduleRuntime: ModuleRuntime(configurationStore: configuration, recoveryStore: configuration), + configurationStore: configuration, + settings: InMemoryPluginSettings(), + launchMode: .normal, + startup: MacPluginStartupResult(installedPlugins: scan.packages, registeredPlugins: [], issues: []), + allowsLocalDevelopmentPackages: true + ) + let replacementID = PluginID("dev.example.replacement") + try manager.installPackage(at: lifecyclePackage(root: root, id: replacementID.rawValue)) + let snapshot = try #require(manager.snapshots.first { $0.id == replacementID }) + #expect(snapshot.hasChangesPendingRestart) + return snapshot + } + private func temporaryRoot() -> URL { FileManager.default.temporaryDirectory .appendingPathComponent("lithe-plugin-manager-\(UUID().uuidString)", isDirectory: true) diff --git a/macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift b/macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift index 6d28caf614..04a27697a8 100644 --- a/macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift +++ b/macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift @@ -302,11 +302,54 @@ struct PluginMarketplaceFeatureModelTests { func officialPluginMovedToTheCommunityHasNoOfficialSource() throws { let retiredID = OfficialPluginCatalog.phpPluginID let (feature, _, manager) = try makeModel(staleOfficialIDs: [retiredID.rawValue]) + manager.snapshots = [try retiredPHPSnapshot(status: .installed)] + + #expect(OfficialPluginCatalog.entry(for: retiredID)?.communityReplacementID + == PluginID("io.github.lithe-idea.php-support")) + #expect(!feature.listings.contains { $0.pluginID == retiredID }) + #expect(feature.listings.contains { $0.pluginID.rawValue == "dev.lithe.plugin.sample" }) + #expect(feature.installedSourceID(for: retiredID) == nil) + #expect(feature.availableUpdate(for: retiredID) == nil) + #expect(feature.canUninstall(retiredID)) + } + + /// The community plugin declares the same language as the retired one, + /// so it cannot be installed until the old package is gone after restart. + @Test + func communityReplacementWaitsUntilTheRetiredPluginIsRemoved() async throws { + let replacementID = "io.github.lithe-idea.php-support" + let (feature, service, manager) = try makeModel(extraCommunityIDs: [replacementID]) + service.origin = .community + let listing = try #require(feature.listings.first { $0.pluginID.rawValue == replacementID }) + let offer = try #require(feature.offer(for: listing)) + + manager.snapshots = [try retiredPHPSnapshot(status: .installed)] + #expect(feature.installState(for: offer) == .blockedByRetiredPlugin(name: "PHP Support", uninstallPending: false)) + await feature.install(offer) + #expect(feature.pendingConfirmation == nil) + #expect(manager.installed.isEmpty) + + manager.snapshots = [try retiredPHPSnapshot(status: .uninstallPending, hasChangesPendingRestart: true)] + #expect(feature.installState(for: offer) == .blockedByRetiredPlugin(name: "PHP Support", uninstallPending: true)) + #expect(feature.hasChangesPendingRestart) + + // After the restart the old package is gone. + manager.snapshots = [] + #expect(feature.installState(for: offer) == .installable) + #expect(!feature.hasChangesPendingRestart) + await feature.install(offer) + #expect(feature.pendingConfirmation != nil) + } + + private func retiredPHPSnapshot( + status: PluginInstallationStatus, + hasChangesPendingRestart: Bool = false + ) throws -> PluginManagementSnapshot { let manifest = try DeclarativePluginTestSupport.officialDescriptor("PhpSupport").pluginManifest - manager.snapshots = [PluginManagementSnapshot( + return PluginManagementSnapshot( manifest: manifest, origin: .marketplace, - installationStatus: .installed, + installationStatus: status, isEnabled: true, isRequired: false, isRunning: false, @@ -322,16 +365,9 @@ struct PluginMarketplaceFeatureModelTests { archiveSHA256: String(repeating: "1", count: 64), contentDigest: String(repeating: "2", count: 64), approvedPrograms: [] - ) - )] - - #expect(OfficialPluginCatalog.entry(for: retiredID)?.communityReplacementID - == PluginID("io.github.lithe-idea.php-support")) - #expect(!feature.listings.contains { $0.pluginID == retiredID }) - #expect(feature.listings.contains { $0.pluginID.rawValue == "dev.lithe.plugin.sample" }) - #expect(feature.installedSourceID(for: retiredID) == nil) - #expect(feature.availableUpdate(for: retiredID) == nil) - #expect(feature.canUninstall(retiredID)) + ), + hasChangesPendingRestart: hasChangesPendingRestart + ) } @Test @@ -374,6 +410,7 @@ struct PluginMarketplaceFeatureModelTests { private func makeModel( alsoListCommunityInOfficial: Bool = false, staleOfficialIDs: [String] = [], + extraCommunityIDs: [String] = [], communitySource community: PluginMarketplaceSource = Self.community ) throws -> (PluginMarketplaceFeatureModel, FakeMarketplaceService, FakePluginManager) { let official = MacPluginMarketplaceService.officialSource @@ -392,7 +429,9 @@ struct PluginMarketplaceFeatureModelTests { community.id: PluginMarketplaceIndex(plugins: [ Self.entry(id: "io.example.windows-only", date: "2026-08-01", platforms: ["windows-x64"]), Self.entry(id: "io.example.zig-support", date: "2026-10-10", platforms: ["darwin-arm64"]), - ], rejected: []), + ] + extraCommunityIDs.map { + Self.entry(id: $0, date: "2026-10-20", platforms: ["darwin-arm64"]) + }, rejected: []), ] ) let manager = FakePluginManager() diff --git a/shared/platform-feature-matrix/features/go-support-plugin.json b/shared/platform-feature-matrix/features/go-support-plugin.json index b42171f6a1..a6cc660ff7 100644 --- a/shared/platform-feature-matrix/features/go-support-plugin.json +++ b/shared/platform-feature-matrix/features/go-support-plugin.json @@ -10,7 +10,10 @@ "macos/Sources/Lithe/Views/App/PluginManagementView.swift", "macos/Sources/Lithe/Application/Features/PluginMarketplace.swift", "macos/Tests/LitheTests/LanguageExtensionProcessLifecycleTests.swift", - "shared/contracts/plugins/v1/fixtures/command-plans.json" + "shared/contracts/plugins/v1/fixtures/command-plans.json", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift", + "macos/Sources/Lithe/Platform/MacOS/MacApplicationRelauncher.swift", + "macos/Tests/LitheTests/PluginManagerTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -23,5 +26,5 @@ "verificationStatus": "not-applicable" }, "owner": "Go Support(社区插件 Lithe-IDEA/lithe-plugin-go)", - "verification": "macOS 打开插件管理,确认“Lithe 官方”源不再列出 Go Support,而 Lithe Store 社区源列出 io.github.lithe-idea.go-support 0.1.0;安装后启用无需重启,打开含 go.mod 的项目并点开 .go 文件,确认使用用户安装的 gopls 启动语言服务器;运行 main.go、运行整套与单文件、单个测试函数的 go test;禁用后 gopls 和运行进程退出。未安装 gopls 时给出缺失提示而不是崩溃。已安装旧官方包 dev.lithe.plugin.go-support 的环境:插件管理显示“已迁移到社区插件”提示、没有官方更新、可以卸载,旧包仍按官方签名要求校验;卸载后从市场安装社区版。Windows 暂无 Go 插件。" + "verification": "macOS 打开插件管理,确认“Lithe 官方”源不再列出 Go Support,而 Lithe Store 社区源列出 io.github.lithe-idea.go-support 0.1.0;安装后启用无需重启,打开含 go.mod 的项目并点开 .go 文件,确认使用用户安装的 gopls 启动语言服务器;运行 main.go、运行整套与单文件、单个测试函数的 go test;禁用后 gopls 和运行进程退出。未安装 gopls 时给出缺失提示而不是崩溃。已安装旧官方包 dev.lithe.plugin.go-support 的环境:插件管理显示“已迁移到社区插件”提示、没有官方更新、可以卸载;旧包还在时插件市场里社区版显示“需先卸载旧版”且不可安装,卸载后点“重启 Lithe”按钮(未保存文件和运行中进程照常确认,取消则不重启)重启进入同一个应用,再安装社区版并重启,确认社区版是启用状态、LSP/运行/测试可用;卸载前手动关闭过的则保持关闭,旧包仍按官方签名要求校验;卸载后从市场安装社区版。Windows 暂无 Go 插件。" } diff --git a/shared/platform-feature-matrix/features/php-optional-plugin.json b/shared/platform-feature-matrix/features/php-optional-plugin.json index c5e7e7d048..be64451327 100644 --- a/shared/platform-feature-matrix/features/php-optional-plugin.json +++ b/shared/platform-feature-matrix/features/php-optional-plugin.json @@ -12,7 +12,10 @@ "macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", "macos/Sources/Lithe/Application/Features/PluginMarketplace.swift", "macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift", - "macos/Tests/LitheTests/PluginPackageStoreTests.swift" + "macos/Tests/LitheTests/PluginPackageStoreTests.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift", + "macos/Sources/Lithe/Platform/MacOS/MacApplicationRelauncher.swift", + "macos/Tests/LitheTests/PluginManagerTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -29,5 +32,5 @@ "verificationStatus": "pending" }, "owner": "PHP Support(macOS 社区插件 Lithe-IDEA/lithe-plugin-php)", - "verification": "macOS 在干净安装上打开插件管理,确认“Lithe 官方”源不再列出 PHP,Lithe Store 社区源列出 io.github.lithe-idea.php-support 0.1.0;安装后启用无需重启。社区包不打包 Intelephense(其许可证禁止再分发):未安装时 LSP 显示缺失提示而不是崩溃;`npm install -g intelephense` 后从 Dock 启动(精简 PATH)打开 PHP 项目并点开 `.php` 文件,确认 intelephense 通过 `LITHE_NODE_PATH` 找到 Homebrew 或 nvm/volta/fnm 安装的 Node 并启动。已安装旧官方包 dev.lithe.plugin.php-support 的环境:插件管理显示“已迁移到社区插件”提示、没有官方更新、可以卸载;对旧官方包将 plugin.json 的 signatureRequirement 从 publisherPackage 篡改为 sameTeamAsHost,确认 MacPluginPackageStore 仍按宿主 OfficialPluginCatalog 拒绝降级;旧包与社区包同时安装时,社区包因语言冲突被拒绝,需先卸载旧包。Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。" + "verification": "macOS 在干净安装上打开插件管理,确认“Lithe 官方”源不再列出 PHP,Lithe Store 社区源列出 io.github.lithe-idea.php-support 0.1.0;安装后启用无需重启。社区包不打包 Intelephense(其许可证禁止再分发):未安装时 LSP 显示缺失提示而不是崩溃;`npm install -g intelephense` 后从 Dock 启动(精简 PATH)打开 PHP 项目并点开 `.php` 文件,确认 intelephense 通过 `LITHE_NODE_PATH` 找到 Homebrew 或 nvm/volta/fnm 安装的 Node 并启动。已安装旧官方包 dev.lithe.plugin.php-support 的环境:插件管理显示“已迁移到社区插件”提示、没有官方更新、可以卸载;旧包还在时插件市场里社区版显示“需先卸载旧版”且不可安装,卸载后点“重启 Lithe”按钮(未保存文件和运行中进程照常确认,取消则不重启)重启进入同一个应用,再安装社区版并重启,确认社区版是启用状态、LSP/运行/测试可用;卸载前手动关闭过的则保持关闭;对旧官方包将 plugin.json 的 signatureRequirement 从 publisherPackage 篡改为 sameTeamAsHost,确认 MacPluginPackageStore 仍按宿主 OfficialPluginCatalog 拒绝降级;旧包与社区包同时安装时,社区包因语言冲突被拒绝,需先卸载旧包。Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。" } diff --git a/shared/platform-feature-matrix/features/plugin-marketplace.json b/shared/platform-feature-matrix/features/plugin-marketplace.json index 87948f4c51..34676ae697 100644 --- a/shared/platform-feature-matrix/features/plugin-marketplace.json +++ b/shared/platform-feature-matrix/features/plugin-marketplace.json @@ -31,7 +31,10 @@ "scripts/verify-runtime-bundle-immutability.sh", "rust/lithe-core/src/plugins/marketplace.rs", "Plugins/marketplace/index.json", - ".github/workflows/release-official-plugins.yml" + ".github/workflows/release-official-plugins.yml", + "macos/Sources/Lithe/Views/App/ApplicationRestartAction.swift", + "macos/Sources/Lithe/Platform/MacOS/MacApplicationRelauncher.swift", + "macos/Tests/LitheTests/MacApplicationRelauncherTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -47,5 +50,5 @@ }, "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 不变。插件详情显示作者、来源仓库、标签、版本、许可证、发布日期和支持平台;索引里没有当前架构的包时显示“暂不支持 <平台> 平台”且不可安装,插件 API 版本不满足时显示不支持原因。在“管理插件仓库”中添加一个 GitHub 仓库地址,确认解析到仓库根目录的 lithe-plugin-index.json,可刷新、停用和删除;第三方源列出 dev.lithe.* 插件时该条目被忽略。安装社区插件时先校验 SHA-256(不匹配则不解压),超过下载上限的响应在下载中途被中止,解压后过大或文件过多的压缩包(含谎报大小的 ZIP)被拒绝且不留临时文件,再弹出确认框列出权限、要运行的程序和来源仓库,取消则不安装;已安装后改动插件目录内任意文件,重启后该插件拒绝加载;本地磁盘社区首次安装、更新、重装和同版本替换每次都必须明确确认,即使权限和程序未变;市场入口保持首次/缺少审批/出现 newPrograms 时确认的既有规则,无新程序的市场更新可沿用审批。确认框展示 Rust installReview 的全部权限与完整 canonical program 内容,包括参数、参数模板、验证探测、初始化选项、运行时与构建声明,已识别程序展开 Full Program Declaration 阅读完整 JSON,未知程序直接显示完整 JSON;同一插件出现在多个源时由用户选源,更新只走安装时记录的源。官方源只接受 Lithe 签名包。分别在英文和简体中文界面下检查列表、详情、安装确认框和仓库管理窗口,文字(含刷新时间格式)都跟随 Lithe 的语言设置,插件描述在有 localizedDescriptions 翻译时显示对应语言,否则显示默认英文描述;用辅助功能“按下”列表行只会选中,不会触发安装。索引里最新版本在本机不可用时,详情的版本、发布日期和平台显示实际会安装的版本,并单独注明最新版本不可用。磁盘验收:安装声明内置 Python 或其他已安装插件语言的社区包,重启拒绝冲突包后,原拥有者未设置、显式关闭或开启的偏好均保持原值;正常首次确认安装仍自动启用,更新保留选择。分别在市场下载和磁盘导入入口构造显式 ZIP 条目未超限但唯一隐式父目录超限的包,以及累计规范化路径 UTF-8 字节超限的包,确认在启动解压前拒绝并清理;显式目录与共享隐式父目录只计一次,恰好预算的合法包可解压。在隔离的用户配置与注入临时根下,从齿轮菜单分别选择一个合法社区 ZIP 与解包目录;准备后检查只生成本次 lithe-plugin-disk- 私有快照,ZIP 的 archiveSHA256 与快照 archive.zip 实测 SHA-256 相同,目录无归档摘要,界面显示本地文件名/内容摘要并明确未认证发布者或仓库。先取消,确认未安装且本次目录删除;再确认安装并重启,核对 installation.json 仅有 disk 来源、内容 digest 与完整 canonical approvedPrograms,没有伪造 marketplace 来源。分别导入程序未变的新版、相同版本原包及相同版本改过脚本的包,每次必须完整展示权限和程序并等待确认;取消不得改变已安装版本。确认框等待时更换原始 ZIP/目录,安装内容应仍等于准备快照;在受控测试中改动已准备快照后提交,应在 install staging 重验失败;安装后增加/删除/改动文件应在 scan 拒绝加载,改动上一版本后 rollback 应失败。正常升级再回滚时,当前/上一版本各自保留 origin、disk/marketplace 来源;同版本替换保留既有上一版本。刷新包含同 ID 的市场源,确认磁盘版本不出现可自动接管的更新/重装入口。以符号链接、Finder alias、特殊文件、多个 plugin.json、超过 64 层的目录、落在源目录内的临时根、中央目录/local header 路径类型冲突或重复条目的 ZIP、超限或损坏 ZIP、非法声明、冒用官方 ID/namespace 的包验证拒绝与清理;官方 publisher 签名、sameTeamAsHost 的 bundle 边界及仅 DEBUG 可用的 localDevelopment 包仍按原策略。导入和待确认期间关闭插件页,确认任务取消、已有快照立即清理、迟到的准备结果只清理而不会安装;成功、取消和失败后检查本次快照和 staging 无残留;在典型工作流前后比较发行 bundle 文件清单/哈希相同,关闭本次应用与子进程。静态及资源验证命令:node scripts/test-reuse-worktree-resources.mjs、./scripts/verify-agent-notes.sh、./scripts/verify-platform-feature-matrix.sh、./scripts/verify-runtime-bundle-immutability.sh;macOS 产品测试使用 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|PluginPackageStore|PluginManager',核对报告包含实际磁盘用例,未运行产品验收前保持 pending。Windows 当前仅共享索引契约、fixture 和 Rust Core 校验,无插件市场 UI 或此声明式 ZIP/目录快照审批流程,现有 worker-package 导入属于另一格式,实现状态保持 partial。" + "verification": "macOS 打开插件管理,确认有“插件市场 / 已安装”两个标签和齿轮菜单;默认有“Lithe 官方”源(不能删除)和预置的社区源 Lithe Store(默认开启、可停用和删除,删除后重启不会再出现),Lithe Store 中能看到 Lua、Go、PHP 三个社区语言插件并可安装,“Lithe 官方”源当前没有可下载插件(Go/PHP 已迁到 Lithe Store),刷新后索引缓存写在 Caches 而 app bundle 不变。安装、更新或卸载插件后,插件管理顶部出现“插件变更将在重启 Lithe 后生效”横幅和“重启 Lithe”按钮,市场列表与详情里的“重启后生效”也变成该按钮;点击后走正常退出确认,确认后应用退出并自动重新打开(不会多开),取消任一确认则不重启。插件详情显示作者、来源仓库、标签、版本、许可证、发布日期和支持平台;索引里没有当前架构的包时显示“暂不支持 <平台> 平台”且不可安装,插件 API 版本不满足时显示不支持原因。在“管理插件仓库”中添加一个 GitHub 仓库地址,确认解析到仓库根目录的 lithe-plugin-index.json,可刷新、停用和删除;第三方源列出 dev.lithe.* 插件时该条目被忽略。安装社区插件时先校验 SHA-256(不匹配则不解压),超过下载上限的响应在下载中途被中止,解压后过大或文件过多的压缩包(含谎报大小的 ZIP)被拒绝且不留临时文件,再弹出确认框列出权限、要运行的程序和来源仓库,取消则不安装;已安装后改动插件目录内任意文件,重启后该插件拒绝加载;本地磁盘社区首次安装、更新、重装和同版本替换每次都必须明确确认,即使权限和程序未变;市场入口保持首次/缺少审批/出现 newPrograms 时确认的既有规则,无新程序的市场更新可沿用审批。确认框展示 Rust installReview 的全部权限与完整 canonical program 内容,包括参数、参数模板、验证探测、初始化选项、运行时与构建声明,已识别程序展开 Full Program Declaration 阅读完整 JSON,未知程序直接显示完整 JSON;同一插件出现在多个源时由用户选源,更新只走安装时记录的源。官方源只接受 Lithe 签名包。分别在英文和简体中文界面下检查列表、详情、安装确认框和仓库管理窗口,文字(含刷新时间格式)都跟随 Lithe 的语言设置,插件描述在有 localizedDescriptions 翻译时显示对应语言,否则显示默认英文描述;用辅助功能“按下”列表行只会选中,不会触发安装。索引里最新版本在本机不可用时,详情的版本、发布日期和平台显示实际会安装的版本,并单独注明最新版本不可用。磁盘验收:安装声明内置 Python 或其他已安装插件语言的社区包,重启拒绝冲突包后,原拥有者未设置、显式关闭或开启的偏好均保持原值;正常首次确认安装仍自动启用,更新保留选择。分别在市场下载和磁盘导入入口构造显式 ZIP 条目未超限但唯一隐式父目录超限的包,以及累计规范化路径 UTF-8 字节超限的包,确认在启动解压前拒绝并清理;显式目录与共享隐式父目录只计一次,恰好预算的合法包可解压。在隔离的用户配置与注入临时根下,从齿轮菜单分别选择一个合法社区 ZIP 与解包目录;准备后检查只生成本次 lithe-plugin-disk- 私有快照,ZIP 的 archiveSHA256 与快照 archive.zip 实测 SHA-256 相同,目录无归档摘要,界面显示本地文件名/内容摘要并明确未认证发布者或仓库。先取消,确认未安装且本次目录删除;再确认安装并重启,核对 installation.json 仅有 disk 来源、内容 digest 与完整 canonical approvedPrograms,没有伪造 marketplace 来源。分别导入程序未变的新版、相同版本原包及相同版本改过脚本的包,每次必须完整展示权限和程序并等待确认;取消不得改变已安装版本。确认框等待时更换原始 ZIP/目录,安装内容应仍等于准备快照;在受控测试中改动已准备快照后提交,应在 install staging 重验失败;安装后增加/删除/改动文件应在 scan 拒绝加载,改动上一版本后 rollback 应失败。正常升级再回滚时,当前/上一版本各自保留 origin、disk/marketplace 来源;同版本替换保留既有上一版本。刷新包含同 ID 的市场源,确认磁盘版本不出现可自动接管的更新/重装入口。以符号链接、Finder alias、特殊文件、多个 plugin.json、超过 64 层的目录、落在源目录内的临时根、中央目录/local header 路径类型冲突或重复条目的 ZIP、超限或损坏 ZIP、非法声明、冒用官方 ID/namespace 的包验证拒绝与清理;官方 publisher 签名、sameTeamAsHost 的 bundle 边界及仅 DEBUG 可用的 localDevelopment 包仍按原策略。导入和待确认期间关闭插件页,确认任务取消、已有快照立即清理、迟到的准备结果只清理而不会安装;成功、取消和失败后检查本次快照和 staging 无残留;在典型工作流前后比较发行 bundle 文件清单/哈希相同,关闭本次应用与子进程。静态及资源验证命令:node scripts/test-reuse-worktree-resources.mjs、./scripts/verify-agent-notes.sh、./scripts/verify-platform-feature-matrix.sh、./scripts/verify-runtime-bundle-immutability.sh;macOS 产品测试使用 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|PluginPackageStore|PluginManager',核对报告包含实际磁盘用例,未运行产品验收前保持 pending。Windows 当前仅共享索引契约、fixture 和 Rust Core 校验,无插件市场 UI 或此声明式 ZIP/目录快照审批流程,现有 worker-package 导入属于另一格式,实现状态保持 partial。" }