From e9c17dbd67796013a5fce297d56248928dcfe1bc Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 30 Sep 2026 01:47:03 +0800 Subject: [PATCH 1/5] feat(macos): make PHP support an optional plugin Download and manage the PHP plugin and its Intelephense runtime through Plugin Management, while keeping LSP controls project-scoped. Publish signed macOS plugin archives and validate plugin-owned language-server resources. Closes #922 --- ...-php-support-plugin-ownership-and-plans.md | 17 +- .github/workflows/release-macos.yml | 32 ++- .github/workflows/release-preview-macos.yml | 54 ++++- .../Official/PhpSupport/language-server.json | 12 + Plugins/mac/README.md | 22 +- docs/ci-builds.md | 6 +- docs/development/platform-parity-matrix.csv | 2 +- docs/development/platform-parity-matrix.md | 2 +- macos/Resources/en.lproj/Localizable.strings | 6 + .../zh-Hans.lproj/Localizable.strings | 6 + .../Features/PluginManagement.swift | 2 + .../PluginLanguageProviderCatalogSource.swift | 13 +- .../AppModel/AppModel+PluginManagement.swift | 27 +++ .../Platform/MacOS/MacServiceContainer.swift | 19 +- ...PluginLanguageServerPackageValidator.swift | 83 +++++++ .../MacOS/Plugins/MacPluginManager.swift | 54 ++++- .../Plugins/MacPluginPackageDownloader.swift | 225 ++++++++++++++++++ .../MacOS/Plugins/MacPluginPackageStore.swift | 61 ++++- .../Runtime/MacRuntimeToolDiscovery.swift | 20 ++ .../Views/App/PluginManagementView.swift | 67 +++++- .../Views/Language/LSPControlCenterView.swift | 72 +++++- .../LitheTests/AppLocalizationTests.swift | 3 + .../LanguageProviderCatalogSourceTests.swift | 51 ++++ .../MacPluginPackageDownloaderTests.swift | 43 ++++ .../MacRuntimeToolDiscoveryTests.swift | 38 +++ .../LitheTests/PluginPackageStoreTests.swift | 170 +++++++++++++ scripts/build-official-plugins.sh | 14 ++ scripts/prepare-php-language-server.sh | 125 ++++++++++ scripts/preview.sh | 5 +- scripts/reuse-worktree-resources.mjs | 5 +- scripts/test-reuse-worktree-resources.mjs | 10 + scripts/verify-official-plugins.sh | 12 + scripts/worktree-resources.json | 22 +- shared/platform-feature-matrix.json | 7 +- 34 files changed, 1265 insertions(+), 42 deletions(-) create mode 100644 Plugins/mac/Official/PhpSupport/language-server.json create mode 100644 macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerPackageValidator.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageDownloader.swift create mode 100644 macos/Tests/LitheTests/MacPluginPackageDownloaderTests.swift create mode 100644 macos/Tests/LitheTests/MacRuntimeToolDiscoveryTests.swift create 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 e617a9bab..995e4e5c3 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 @@ -16,7 +16,8 @@ PHP 支持由用户选择安装和启用,主程序不携带 PHP 插件包、No - `scripts/official-plugin-distribution.mjs` 显式列出随主程序分发的官方插件。PHP 不在其中;`build-official-plugins.sh --plugin-id dev.lithe.plugin.php-support` 仍能独立构建插件包,用户通过已有插件管理的 Install 入口安装签名与宿主一致的包。 - macOS 的 PHP 语言服务和执行模块均默认禁用、按需激活。安装后的包提供 `.php`、`.phtml` 和 `composer.json` 声明;没有包时不注册它的进程能力。共享的轻量语法识别不需要下载或启动外部进程。 -- macOS 使用用户指定或 PATH 中的 Intelephense 和 PHP,设置页提供工具配置和官方下载入口。Lithe 不安装或删除这部分用户工具。 +- macOS 的 PHP 插件包携带由 `language-server.json` 固定版本和 SHA-256 的 Intelephense 包。插件管理页下载并校验插件包,运行时把 Intelephense 保存在同一个用户级插件版本目录中;重装、回滚和卸载都处理同一份目录。Node.js 仍是 Intelephense 的外部运行时,PHP、Composer 和项目 PHPUnit 仍由用户自行提供;本地目录导入只用于离线或故障恢复。 +- macOS 下载地址使用宿主 App 的 `CFBundleShortVersionString` 组成 Release tag 和 zip 文件名;`BuiltInPluginCatalog.hostVersion` 只用于插件 API 兼容性校验,不能用来定位发布资产。 - Windows 的配置、Composer/PHPUnit 解析和入口位于 `Plugins/win/Official/PhpSupport`,通过独立 `.lithe-extension` 包分发。宿主不能静态或动态 import 这份实现;只有轻量的语言到包 ID 对应表保留在宿主。插件管理的“导入插件包”入口安装后默认禁用,用户再选择启用。 - Windows 显式安装 PHP 扩展时才下载解析器,并使用用户的 Bun 安装 Intelephense;同时检查 Node.js,因为安装包管理器与语言服务器运行时不是同一概念。缺失时给出安装引导,不后台下载运行时。 - Windows 启动时发现 PHP 工具缺失只报告状态,不自动重装。卸载删除插件自己的解析器和 `/language-tools/php`,不会删除 PATH、全局 npm/Bun 或项目 `vendor`。 @@ -24,6 +25,7 @@ PHP 支持由用户选择安装和启用,主程序不携带 PHP 插件包、No ### 能力与生命周期 - PHP 的 LSP 使用现有 Rust Core 会话,以 `intelephense --stdio` 启动。符号、类型和诊断仍由上游服务拥有;主机不实现第二套 PHP 语义分析。 +- macOS 插件管理页是 PHP 包和 Intelephense 的生命周期唯一入口:构建阶段按 `language-server.json` 下载并校验 npm tarball,把 launcher 和运行包放入插件 bundle;下载器再下载完整插件 zip,`MacPluginPackageStore` 同时验证插件 manifest、签名和语言服务器 launcher。安装、重装、回滚和卸载都针对同一个插件版本目录执行,因此不会留下脱离插件的 LSP。LSP 控制中心只显示当前项目的 PHP 语言服务器开关和运行状态,发现未安装、未启用或待重启时引导回插件管理页,不提供包操作按钮。 - macOS 运行和测试使用插件模块持有的执行 session。相对文件名不做 trim,以 `-` 开头时加 `./`,避免把文件名当作命令选项。 - Windows 只有已安装且启用 PHP 扩展时才读取 Composer/PHPUnit 清单、展示运行入口;执行前再次检查开关。Composer 的字符串和字符串数组均交给 `composer run -- ` 执行,不在主机模拟脚本语义。 - Windows PHP 运行复用 Run 的输出面板和 native 进程启动能力,通用宿主服务在首个 await 之前按插件 ID 和工作区登记会话。禁用或关闭工作区时等待在途启动,再停止其拥有的 execution ID;自然结束释放所有权。不得通过普通终端事件绕过这个流程。 @@ -55,7 +57,7 @@ PHP 支持由用户选择安装和启用,主程序不携带 PHP 插件包、No ## 后果 -不使用 PHP 的用户不承担语言服务器下载、索引和进程成本。代价是首次使用需要显式安装插件及本机工具;macOS 插件分发需要与宿主一致的签名。Windows 目前提供 Composer 脚本及整套 PHPUnit,未声明支持 macOS 已有的单方法测试发现。Windows 本地包暂不提供在线分发、自动更新或签名身份验证,替换版本需先卸载再导入;包格式仅用于小型 Worker 语言插件。目标平台运行验证未完成前,功能矩阵保持 pending。 +不使用 PHP 的用户不承担语言服务器下载、索引和进程成本。代价是首次使用需要显式安装插件和 Node.js;macOS 在线包必须使用与宿主一致的签名,未配置 Developer ID 的调试或预览构建仍只能使用本地导入进行测试。Windows 目前提供 Composer 脚本及整套 PHPUnit,未声明支持 macOS 已有的单方法测试发现。Windows 本地包暂不提供在线分发、自动更新或签名身份验证,替换版本需先卸载再导入;包格式仅用于小型 Worker 语言插件。目标平台运行验证未完成前,功能矩阵保持 pending。 插件构建产物、PHPUnit 的 vendor 和应用语言工具缓存没有可靠的跨工作树身份标记,均在 `scripts/worktree-resources.json` 的 excludedResources 中排除。不得把它们共享为可变缓存。 @@ -67,13 +69,22 @@ PHP 支持由用户选择安装和启用,主程序不携带 PHP 插件包、No - `node scripts/test-official-plugin-distribution.mjs`:默认分发名单不含 PHP,未知插件不会意外打入主程序。 - `./scripts/verify-macos-package.sh`:实际组装产物不得含 PHP 插件。 - `./scripts/verify-official-plugins.sh`:独立包兼容性与签名验证。 +- `MacPluginPackageDownloaderTests`:验证 stable/preview 发行资产 URL 按 App 发布版本和架构确定性生成。 +- `MacRuntimeToolDiscoveryTests`:验证启用的 PHP 插件版本目录优先提供 Intelephense launcher。 +- `PluginPackageStoreTests/reinstallCanReplaceTheActiveVersionOnlyAfterValidation`:验证重装不会绕过签名校验,并在校验完成后替换当前版本。 +- `prepare-php-language-server.sh`:按 JSON 清单下载、校验并组装 Intelephense 运行包;插件版本目录删除时一并删除 launcher 和缓存文件。 +- `.github/workflows/release-macos.yml`:Developer ID 构建额外发布架构对应的 PHP 插件 zip;未配置 Developer ID 时不发布可在线安装的独立包。 - `./scripts/test-macos.sh --filter LithePhpSupportModuleTests`:模块、路径与禁用清理测试。 -- `LITHE_RUN_PHP_INTEGRATION=1 ./scripts/test-macos.sh --filter RealPhpIntegrationTests`:真实工具测试;先在 `shared/fixtures/phpunit-project` 执行 `composer install`,并配置 Intelephense 路径。 +- `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/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerPackageValidator.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/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 06eb6a9be..9d94a6558 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -127,6 +127,19 @@ jobs: run: | test -n "$LITHE_SPARKLE_PUBLIC_KEY" || { echo "Configure SPARKLE_PUBLIC_KEY before releasing"; exit 1; } ./scripts/package-app.sh + if [[ -n "${LITHE_CODESIGN_IDENTITY:-}" ]]; then + plugin_root=$(LITHE_CODESIGN_IDENTITY="$LITHE_CODESIGN_IDENTITY" \ + scripts/build-official-plugins.sh \ + --configuration release \ + --triple "${LITHE_ARCH}-apple-macosx" \ + --plugin-id dev.lithe.plugin.php-support \ + --output "dist/official-plugins/${LITHE_ARCH}") + ditto -c -k --sequesterRsrc --keepParent \ + "$plugin_root/dev.lithe.plugin.php-support" \ + "dist/Lithe-PHP-Support-${LITHE_VERSION}-${LITHE_ARCH}.zip" + else + echo "Developer ID is not configured; skipping externally installable PHP plugin archive." + fi ./scripts/create-dmg.sh dmg_name="Lithe-${LITHE_VERSION}-${LITHE_ARCH}.dmg" (cd dist && shasum -a 256 "$dmg_name" > "$dmg_name.sha256") @@ -154,6 +167,10 @@ jobs: -c "Print :${build_key}" \ "$app_path/Contents/Info.plist" done + plugin_archive="dist/Lithe-PHP-Support-${LITHE_VERSION}-${LITHE_ARCH}.zip" + if [[ -e "$plugin_archive" ]]; then + test -s "$plugin_archive" + fi lipo "$app_path/Contents/MacOS/Lithe" -verify_arch "$LITHE_ARCH" hdiutil imageinfo "$dmg_path" > /dev/null test -s "$dmg_path" @@ -180,6 +197,15 @@ jobs: if-no-files-found: error retention-days: 7 + - name: Upload optional PHP plugin archive + if: ${{ hashFiles(format('dist/Lithe-PHP-Support-{0}-{1}.zip', needs.prepare.outputs.version, matrix.architecture)) != '' }} + uses: actions/upload-artifact@v7 + with: + name: macos-php-plugin-${{ matrix.architecture }} + path: dist/Lithe-PHP-Support-${{ needs.prepare.outputs.version }}-${{ matrix.architecture }}.zip + if-no-files-found: error + retention-days: 7 + - name: Remove signing keychain if: always() run: | @@ -206,7 +232,7 @@ jobs: - name: Download release assets uses: actions/download-artifact@v8 with: - pattern: macos-release-* + pattern: macos-* path: dist merge-multiple: true @@ -261,6 +287,10 @@ jobs: ) for architecture in arm64 x86_64; do upload_args+=(dist/sparkle-"$architecture"/*) + plugin_archive="dist/Lithe-PHP-Support-${LITHE_VERSION}-${architecture}.zip" + if [[ -e "$plugin_archive" ]]; then + upload_args+=("$plugin_archive") + fi done create_args+=(--notes-file "$notes_file") diff --git a/.github/workflows/release-preview-macos.yml b/.github/workflows/release-preview-macos.yml index 6da93d28b..3598a111b 100644 --- a/.github/workflows/release-preview-macos.yml +++ b/.github/workflows/release-preview-macos.yml @@ -94,6 +94,21 @@ jobs: jdk: "true" jdk-architecture: ${{ matrix.architecture }} + - name: Import Developer ID signing identity + env: + MACOS_SIGNING_CERTIFICATE: ${{ secrets.MACOS_SIGNING_CERTIFICATE }} + MACOS_SIGNING_PASSWORD: ${{ secrets.MACOS_SIGNING_PASSWORD }} + LITHE_CODESIGN_IDENTITY: ${{ vars.MACOS_SIGNING_IDENTITY }} + run: | + if [[ -n "$MACOS_SIGNING_CERTIFICATE" ]]; then + zsh scripts/import-macos-signing.sh + elif [[ -n "$MACOS_SIGNING_PASSWORD" || -n "$LITHE_CODESIGN_IDENTITY" ]]; then + echo "Incomplete Developer ID configuration: provide the certificate or remove the optional identity and password." + exit 1 + else + echo "Developer ID is not configured; the preview app will be ad-hoc signed and no externally installable PHP plugin archive will be published." + fi + - name: Build and package the preview app env: LITHE_ARCH: ${{ matrix.architecture }} @@ -107,6 +122,19 @@ jobs: run: | test -n "$LITHE_SPARKLE_PUBLIC_KEY" || { echo "Configure SPARKLE_PUBLIC_KEY before publishing preview updates"; exit 1; } ./scripts/package-app.sh + if [[ -n "${LITHE_CODESIGN_IDENTITY:-}" ]]; then + plugin_root=$(LITHE_CODESIGN_IDENTITY="$LITHE_CODESIGN_IDENTITY" \ + scripts/build-official-plugins.sh \ + --configuration release \ + --triple "${LITHE_ARCH}-apple-macosx" \ + --plugin-id dev.lithe.plugin.php-support \ + --output "dist/official-plugins/${LITHE_ARCH}") + ditto -c -k --sequesterRsrc --keepParent \ + "$plugin_root/dev.lithe.plugin.php-support" \ + "dist/Lithe-PHP-Support-${LITHE_VERSION}-${LITHE_ARCH}.zip" + else + echo "Developer ID is not configured; skipping externally installable PHP plugin archive." + fi ./scripts/create-dmg.sh dmg_name="Lithe-${LITHE_VERSION}-${LITHE_ARCH}.dmg" (cd dist && shasum -a 256 "$dmg_name" > "$dmg_name.sha256") @@ -151,6 +179,15 @@ jobs: compression-level: 0 retention-days: 14 + - name: Upload optional PHP plugin archive + if: ${{ hashFiles(format('dist/Lithe-PHP-Support-{0}-{1}.zip', env.PREVIEW_VERSION, matrix.architecture)) != '' }} + uses: actions/upload-artifact@v7 + with: + name: macos-php-plugin-${{ matrix.architecture }} + path: dist/Lithe-PHP-Support-${{ env.PREVIEW_VERSION }}-${{ matrix.architecture }}.zip + if-no-files-found: error + retention-days: 14 + - name: Add preview download link env: ARTIFACT_URL: ${{ steps.artifacts.outputs.artifact-url }} @@ -167,6 +204,13 @@ jobs: echo "Requires GitHub sign-in; retained for 14 days. Available before the rolling Release finishes publishing." } >> "$GITHUB_STEP_SUMMARY" + - name: Remove signing keychain + if: always() + run: | + if [[ -f "$RUNNER_TEMP/lithe-signing.keychain-db" ]]; then + security delete-keychain "$RUNNER_TEMP/lithe-signing.keychain-db" + fi + publish: name: Publish rolling macOS preview needs: [prepare, build] @@ -188,7 +232,7 @@ jobs: - name: Download preview artifacts uses: actions/download-artifact@v8 with: - pattern: macos-preview-* + pattern: macos-* path: dist merge-multiple: true @@ -236,6 +280,14 @@ jobs: --repo "$GITHUB_REPOSITORY" \ --clobber + for architecture in arm64 x86_64; do + plugin_archive="dist/Lithe-PHP-Support-${LITHE_VERSION}-${architecture}.zip" + if [[ -e "$plugin_archive" ]]; then + gh release upload "$RELEASE_TAG" "$plugin_archive" \ + --repo "$GITHUB_REPOSITORY" --clobber + fi + done + # Publish every archive before replacing either feed. Retention protects # the current feed and the most recent 30 builds for cached clients. for architecture in arm64 x86_64; do diff --git a/Plugins/mac/Official/PhpSupport/language-server.json b/Plugins/mac/Official/PhpSupport/language-server.json new file mode 100644 index 000000000..15f9ce72c --- /dev/null +++ b/Plugins/mac/Official/PhpSupport/language-server.json @@ -0,0 +1,12 @@ +{ + "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 88c20cc72..0fba03e24 100644 --- a/Plugins/mac/README.md +++ b/Plugins/mac/README.md @@ -15,12 +15,18 @@ LITHE_CODESIGN_IDENTITY="" \ --triple arm64-apple-macosx --plugin-id dev.lithe.plugin.php-support ``` -Use `x86_64-apple-macosx` for Intel. Distribute the resulting -`dev.lithe.plugin.php-support` directory intact; users select that directory in -Plugin Management → Install, then enable PHP Language Server / PHP Execution. -Native package verification still requires the host's signing team. Debug/ad-hoc -CI packages are for testing and are not production distribution artifacts. +Use `x86_64-apple-macosx` for Intel. Keep the resulting +`dev.lithe.plugin.php-support` directory intact for offline recovery; normal +users download, install, reinstall, and uninstall PHP Support from Plugin +Management. After installation and restart, the LSP settings page only controls +the current project's PHP language server. Native package verification still +requires the host's signing team. Debug/ad-hoc CI packages are for local testing +and are not production distribution artifacts. -Configure a user-installed Intelephense executable in Language Server settings -(Node.js is required). Install PHP/Composer and project PHPUnit only when using -run/test. Uninstalling the plugin does not delete user tools or project files. +The PHP plugin archive carries a pinned Intelephense package described by +`language-server.json`. Plugin Management downloads and verifies that tool as +part of the plugin package, stores it under the user-level plugin directory, +and removes it with the plugin. Node.js is still required at runtime because +Intelephense is distributed as a Node.js program. Install PHP/Composer and +project PHPUnit only when using run/test. The plugin never deletes those user +tools or project files. diff --git a/docs/ci-builds.md b/docs/ci-builds.md index f2dd00530..3ad394f31 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -170,6 +170,9 @@ SHA-256;Cargo、SwiftPM 和 Bun 使用各自的 lockfile、版本与完整性 清单校验。 - `.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;它只服务当前工作树的插件打包,不能复制解压结果。 以下目录不应直接复制或跨工作树共享: @@ -192,7 +195,8 @@ SHA-256;Cargo、SwiftPM 和 Bun 使用各自的 lockfile、版本与完整性 identity stamp,任何复制阶段都禁止共享。资源清单 `jdt-maven-settings` 显式排除, 复用脚本直接拒绝该资源,不进入下载或生成物校验路由。 -PHP 插件包在 `.build///OfficialPlugins` 中独立构建,绑定宿主 API、Swift 工具链、架构和签名,通过 `LitheOfficialPluginVerifier` 验证;无可靠 identity stamp,不跨工作树复制。PHPUnit 测试夹具的 `shared/fixtures/phpunit-project/vendor` 也由当前工作树独立安装。应用缓存下 `language-tools//` 是插件拥有的可变运行时资源,随插件卸载清理,不是构建缓存。以上项目在资源清单 `excludedResources` 中明确排除,复用脚本会拒绝显式复制请求。 +PHP 插件包在 `.build///OfficialPlugins` 中独立构建,绑定宿主 API、Swift 工具链、架构和签名,通过 `LitheOfficialPluginVerifier` 验证;无可靠 identity stamp,不跨工作树复制。插件安装后的 Intelephense 位于 +`/Lithe/Plugins//versions//PhpSupport.bundle/Contents/Resources/LanguageServers/php`,由插件版本目录拥有,重装、回滚和卸载随插件一起处理,不是工作树构建缓存。PHPUnit 测试夹具的 `shared/fixtures/phpunit-project/vendor` 也由当前工作树独立安装。以上项目在资源清单 `excludedResources` 中明确排除,复用脚本会拒绝显式复制请求。 如果后续新增可复用资源,必须同步更新注册表、校验器、脚本测试和本节说明。 生成资源只有在构建流程写入可验证的源码、配置、平台、架构和工具链 identity diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 764f84683..d639d80ab 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -105,7 +105,7 @@ wsl-paths,Windows 专属,WSL,WSL 文件与工作区路径,平台专属,不适用 wsl-boundaries,Windows 专属,WSL,跨 WSL 边界移动与重命名,平台专属,不适用,已实现,待验证,Windows Platform,Windows 验证本地、同发行版、跨发行版移动/重命名及明确错误提示。,,macos/Sources/Lithe,windows/tauri/src/features/wsl; windows/tauri/src/features/file-system editor-file-encoding-reopen,编辑器,文本编辑,按指定编码重新打开文本文件,已实现,待验证,已实现,待验证,Editor,在 macOS 和 Windows 实机验证 UTF-8、UTF-8 BOM 与 GBK/GB18030 的自动识别,并分别验证 Shift JIS、Windows-1252 的指定编码重新打开、编码转换保存、脏文件选择和外部修改保护。,,macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift; macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift; macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift; macos/Sources/Lithe/Platform/MacOS/FileSystem/MacDocumentEncoding.swift,windows/tauri/src/features/command-palette/components/encoding-picker.tsx; windows/tauri/src/features/editor/services/document-encoding-workflow.ts; windows/tauri/crates/project/src/document_file.rs editor-file-encoding-save,编辑器,文本编辑,按指定编码保存文本文件,已实现,待验证,已实现,待验证,Editor,在 macOS 和 Windows 实机验证 UTF-8、UTF-8 BOM 与 GBK/GB18030 的自动识别,并分别验证 Shift JIS、Windows-1252 的指定编码重新打开、编码转换保存、脏文件选择和外部修改保护。,,macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift; macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift; macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift; macos/Sources/Lithe/Platform/MacOS/FileSystem/MacDocumentEncoding.swift,windows/tauri/src/features/command-palette/components/encoding-picker.tsx; windows/tauri/src/features/editor/stores/editor-app.store.ts; windows/tauri/crates/project/src/document_file.rs -php-optional-plugin,语言支持,PHP,PHP 按需安装与插件生命周期,已实现,待验证,已实现,待验证,PHP Support,在干净安装上确认没有 PHP 插件运行时;显式安装并启用后验证补全、诊断,安装中取消、运行中禁用、关闭工作区和卸载后确认无插件进程,用户工具保留。 Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。,,Plugins/mac/Official/PhpSupport; scripts/official-plugin-distribution.mjs; macos/Sources/Lithe/Platform/MacOS/Plugins,Plugins/win/Official/PhpSupport; windows/tauri/src/extensions/registry/extension-store-lifecycle.ts; windows/tauri/src-tauri/src/language_tools.rs; windows/tauri/src/extensions/packages/local-extension-package.ts; scripts/verify-windows-plugin-isolation.mjs +php-optional-plugin,语言支持,PHP,PHP 按需安装与插件生命周期,已实现,待验证,已实现,待验证,PHP Support,macOS 在干净安装上打开插件管理,确认可从当前发行渠道下载并安装 PHP、失败时可从磁盘导入;安装后重启,在 LSP 界面只控制当前项目开关和状态,验证重装、卸载均在重启后生效且用户工具保留。Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。,,Plugins/mac/Official/PhpSupport; scripts/official-plugin-distribution.mjs; 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,Plugins/win/Official/PhpSupport; windows/tauri/src/extensions/registry/extension-store-lifecycle.ts; windows/tauri/src-tauri/src/language_tools.rs; windows/tauri/src/extensions/packages/local-extension-package.ts; scripts/verify-windows-plugin-isolation.mjs php-run-test,语言支持,PHP,PHP 插件运行与 PHPUnit 测试,已实现,待验证,部分实现,待验证,PHP Support,macOS 运行 PHP 文件和单条/整套 PHPUnit;Windows 运行字符串/数组 Composer script 和整套 PHPUnit。禁用后菜单消失、运行进程退出,特殊文件名保持原样。Windows 暂不支持单方法发现。,,Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Capabilities/PhpExecutionCapability.swift; macos/Tests/LitheTests/RealPhpIntegrationTests.swift,Plugins/win/Official/PhpSupport/plugin.ts; windows/tauri/src/extensions/run; windows/tauri/src/extensions/ui/services/ui-extension-worker-runtime.test.ts git-workspace-staged-commit,版本控制,Git,按文件所属仓库批量提交及推送,保留每仓库结果,已实现,待验证,已实现,待验证,Git,勾选两个独立仓库文件,一次提交并推送,验证各自 HEAD 和远程;停止一个操作后其余独立仓库继续,已成功仓库不会回滚。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift; macos/Sources/Lithe/Views/Git/CommitAreaView.swift; macos/Tests/LitheGitModuleTests/GitModuleTests.swift; rust/lithe-core/src/git/commit_state.rs; rust/lithe-core/src/tests/git_workspace_commit.rs; rust/lithe-core/src/git/workspace_commit.rs; rust/lithe-core/src/git/workspace_commit/tests.rs; shared/fixtures/git/workspace-commit-workflow-v1.json,windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/git/components/git-commit-panel.tsx; windows/tauri/src/features/git/components/git-workspace-commit-review.tsx; windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts; windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts; windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx; windows/tauri/src-tauri/src/platform.rs; shared/fixtures/git/workspace-commit-workflow-v1.json; windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx; windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx git-submodule-commit-plan,版本控制,Git,子模块先提交与推送、自动更新父引用、计划变化再次确认,已实现,待验证,已实现,待验证,Git,只勾选孙仓库文件,确认计划自动列出父祖仓库的引用更新且可关闭;确认期间改变暂存内容或分支,必须显示新计划再次确认。父仓库未暂存文件不能被带入;只勾选父引用时先推送子仓库现有提交,Git 发布检查应拒绝未发布的子引用。子仓库元数据被外部删除后必须拒绝读取和写入,不能向上回退父仓库。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift; macos/Sources/Lithe/Views/Git/CommitAreaView.swift; macos/Tests/LitheGitModuleTests/GitModuleTests.swift; rust/lithe-core/src/git/commit_state.rs; rust/lithe-core/src/tests/git_workspace_commit.rs; rust/lithe-core/src/git/workspace_commit.rs; rust/lithe-core/src/git/workspace_commit/tests.rs; shared/fixtures/git/workspace-commit-workflow-v1.json,windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/git/components/git-commit-panel.tsx; windows/tauri/src/features/git/components/git-workspace-commit-review.tsx; windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts; windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts; windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx; windows/tauri/src-tauri/src/platform.rs; shared/fixtures/git/workspace-commit-workflow-v1.json; windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx; windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index a2c23cc5e..6e05a4cde 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -280,7 +280,7 @@ | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | -| PHP | **PHP 按需安装与插件生命周期**
php-optional-plugin | ✅ 已实现
🔍 待验证
`Plugins/mac/Official/PhpSupport`、`scripts/official-plugin-distribution.mjs`、`macos/Sources/Lithe/Platform/MacOS/Plugins` | ✅ 已实现
🔍 待验证
`Plugins/win/Official/PhpSupport`、`windows/tauri/src/extensions/registry/extension-store-lifecycle.ts`、`windows/tauri/src-tauri/src/language_tools.rs`、`windows/tauri/src/extensions/packages/local-extension-package.ts`、`scripts/verify-windows-plugin-isolation.mjs` | PHP Support | 在干净安装上确认没有 PHP 插件运行时;显式安装并启用后验证补全、诊断,安装中取消、运行中禁用、关闭工作区和卸载后确认无插件进程,用户工具保留。 Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。 | | +| PHP | **PHP 按需安装与插件生命周期**
php-optional-plugin | ✅ 已实现
🔍 待验证
`Plugins/mac/Official/PhpSupport`、`scripts/official-plugin-distribution.mjs`、`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` | ✅ 已实现
🔍 待验证
`Plugins/win/Official/PhpSupport`、`windows/tauri/src/extensions/registry/extension-store-lifecycle.ts`、`windows/tauri/src-tauri/src/language_tools.rs`、`windows/tauri/src/extensions/packages/local-extension-package.ts`、`scripts/verify-windows-plugin-isolation.mjs` | PHP Support | macOS 在干净安装上打开插件管理,确认可从当前发行渠道下载并安装 PHP、失败时可从磁盘导入;安装后重启,在 LSP 界面只控制当前项目开关和状态,验证重装、卸载均在重启后生效且用户工具保留。Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。 | | | PHP | **PHP 插件运行与 PHPUnit 测试**
php-run-test | ✅ 已实现
🔍 待验证
`Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Capabilities/PhpExecutionCapability.swift`、`macos/Tests/LitheTests/RealPhpIntegrationTests.swift` | 🟡 部分实现
🔍 待验证
`Plugins/win/Official/PhpSupport/plugin.ts`、`windows/tauri/src/extensions/run`、`windows/tauri/src/extensions/ui/services/ui-extension-worker-runtime.test.ts` | PHP Support | macOS 运行 PHP 文件和单条/整套 PHPUnit;Windows 运行字符串/数组 Composer script 和整套 PHPUnit。禁用后菜单消失、运行进程退出,特殊文件名保持原样。Windows 暂不支持单方法发现。 | | diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 08ad2ecb6..355fc7a60 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -12,6 +12,8 @@ "Installed (%lld of %lld enabled)" = "Installed (%lld of %lld enabled)"; "Install Plugin from Disk…" = "Install Plugin from Disk…"; "Install PHP Support from a signed plugin package." = "Install PHP Support from a signed plugin package."; +"Download PHP Support from the official plugin release." = "Download PHP Support from the official plugin release."; +"Download and Install" = "Download and Install"; "Install PHP Support Plugin" = "Install PHP Support Plugin"; "Select a PHP Support plugin package." = "Select a PHP Support plugin package."; "Plugin marketplace is not available" = "Plugin marketplace is not available"; @@ -30,8 +32,12 @@ "Pending confirmation" = "Pending confirmation"; "Pending plugin changes: %lld" = "Pending plugin changes: %lld"; "Uninstall" = "Uninstall"; +"Reinstall" = "Reinstall"; +"Uninstall PHP Support?" = "Uninstall PHP Support?"; +"PHP language support will be removed after restarting Lithe." = "PHP language support will be removed after restarting Lithe."; "Overview" = "Overview"; "No Plugins" = "No Plugins"; +"Open Plugin Management" = "Open Plugin Management"; "Database" = "Database"; "Enabled" = "Enabled"; "Disabled" = "Disabled"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index a87869b24..2e8b7b013 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1340,6 +1340,8 @@ "Installed (%lld of %lld enabled)" = "已安装(%lld / %lld 个已启用)"; "Install Plugin from Disk…" = "从磁盘安装插件…"; "Install PHP Support from a signed plugin package." = "请从已签名的插件包安装 PHP Support。"; +"Download PHP Support from the official plugin release." = "从官方插件版本下载 PHP 支持。"; +"Download and Install" = "下载并安装"; "Install PHP Support Plugin" = "安装 PHP Support 插件"; "Select a PHP Support plugin package." = "请选择 PHP Support 插件包。"; "Plugin marketplace is not available" = "插件市场暂不可用"; @@ -1358,8 +1360,12 @@ "Pending confirmation" = "等待确认"; "Pending plugin changes: %lld" = "待确认的插件修改:%lld"; "Uninstall" = "卸载"; +"Reinstall" = "重新安装"; +"Uninstall PHP Support?" = "卸载 PHP 支持?"; +"PHP language support will be removed after restarting Lithe." = "重启 Lithe 后将移除 PHP 语言支持。"; "Overview" = "概览"; "No Plugins" = "暂无插件"; +"Open Plugin Management" = "打开插件管理"; "Database" = "数据库连接"; "Enabled" = "已启用"; "Disabled" = "已禁用"; diff --git a/macos/Sources/Lithe/Application/Features/PluginManagement.swift b/macos/Sources/Lithe/Application/Features/PluginManagement.swift index 30194467f..a53c5554d 100644 --- a/macos/Sources/Lithe/Application/Features/PluginManagement.swift +++ b/macos/Sources/Lithe/Application/Features/PluginManagement.swift @@ -30,6 +30,8 @@ protocol PluginManaging: AnyObject { var issues: [PluginManagementIssue] { get } func setEnabled(_ enabled: Bool, for pluginID: PluginID) async throws + func download(pluginID: PluginID) async throws + func reinstall(pluginID: PluginID) async throws func installPackage(at packageURL: URL) throws func rollback(_ pluginID: PluginID) throws func uninstall(_ pluginID: PluginID) async throws diff --git a/macos/Sources/Lithe/Core/Language/PluginLanguageProviderCatalogSource.swift b/macos/Sources/Lithe/Core/Language/PluginLanguageProviderCatalogSource.swift index d79265611..59e5691de 100644 --- a/macos/Sources/Lithe/Core/Language/PluginLanguageProviderCatalogSource.swift +++ b/macos/Sources/Lithe/Core/Language/PluginLanguageProviderCatalogSource.swift @@ -62,8 +62,17 @@ extension LanguageProviderCatalog { languageIdentifier: existing?.languageIdentifier ?? support.id, languageIdentifiersByExtension: existing?.languageIdentifiersByExtension ?? [:], languageIdentifiersByFileName: existing?.languageIdentifiersByFileName ?? [:], - languageServerLaunch: nil, - languageServerInstallation: existing?.languageServerInstallation + // The extension owns whether the process may run, while the + // shared catalog still owns the descriptive launch metadata + // used by setup and diagnostics. Keeping this metadata lets + // an installed PHP extension appear in the LSP control + // center without restoring a fallback process path. Package + // owned servers must not expose the generic Homebrew/official + // installer, because Plugin Management owns their lifecycle. + languageServerLaunch: existing?.languageServerLaunch, + languageServerInstallation: support.languageServerModuleID == nil + ? existing?.languageServerInstallation + : nil ) if let existingIndex { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift index fe0628bdb..6e0d58c21 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift @@ -29,6 +29,33 @@ extension AppModel { } } + func downloadPHPPlugin() async { + do { + try await services.pluginManager.download(pluginID: OfficialPluginCatalog.phpPluginID) + objectWillChange.send() + } catch { + showNotification(error.localizedDescription) + } + } + + func reinstallPHPPlugin() async { + do { + try await services.pluginManager.reinstall(pluginID: OfficialPluginCatalog.phpPluginID) + objectWillChange.send() + } catch { + showNotification(error.localizedDescription) + } + } + + func uninstallPHPPlugin() async { + do { + try await services.pluginManager.uninstall(OfficialPluginCatalog.phpPluginID) + objectWillChange.send() + } catch { + showNotification(error.localizedDescription) + } + } + func applyPluginEnabledChanges(_ changes: [PluginID: Bool]) async -> Set { let snapshotsByID = Dictionary(uniqueKeysWithValues: pluginSnapshots.map { ($0.id, $0) }) let closesDatabase = changes.contains { pluginID, enabled in diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 53d793398..04643c860 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -215,10 +215,24 @@ final class MacServiceContainer { } catch { preconditionFailure("Invalid built-in module graph: \(error.localizedDescription)") } + let activePluginIDs = Set(pluginStartup.activeNativeManifests.map(\.id)) + let pluginToolRoots = pluginStartup.installedPlugins + .filter { activePluginIDs.contains($0.manifest.id) } + .compactMap { installed -> URL? in + guard installed.manifest.id == OfficialPluginCatalog.phpPluginID, + case .nativeBundle = installed.manifest.entrypoint.kind, + let bundlePath = installed.manifest.entrypoint.bundlePath else { + return nil + } + return installed.packageURL + .appendingPathComponent(bundlePath, isDirectory: true) + .appendingPathComponent("Contents/Resources/LanguageServers/php", isDirectory: true) + .standardizedFileURL + } let runtimeService = ProjectRuntimeService( runtimeLocator: MacRuntimeLocator(), store: store, - toolDiscovery: MacRuntimeToolDiscovery() + toolDiscovery: MacRuntimeToolDiscovery(pluginToolRoots: pluginToolRoots) ) let rustLanguageProviderCatalogSource = RustLanguageProviderCatalogSource(core: rustCore) // Installation owns the process-backed language boundary even when a @@ -595,7 +609,8 @@ final class MacServiceContainer { configurationStore: moduleStore, launchMode: moduleLaunchMode, startup: pluginStartup, - managedBuiltInPlugins: bundledLanguageManifests + managedBuiltInPlugins: bundledLanguageManifests, + packageDownloader: MacPluginPackageDownloader() ) let pluginCatalog: ValidatedPluginCatalog do { diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerPackageValidator.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerPackageValidator.swift new file mode 100644 index 000000000..b4708701d --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerPackageValidator.swift @@ -0,0 +1,83 @@ +import Foundation +import LitheModuleAPI + +struct MacPluginLanguageServerManifest: Decodable { + let schemaVersion: Int + let pluginID: String + let toolID: String + let version: String + let archiveURL: URL + let archiveSHA256: String + let archiveFormat: String + let archiveRoot: String + let entrypoint: String + let license: String +} + +enum MacPluginLanguageServerPackageValidationError: Error, Equatable, LocalizedError { + case missingManifest + case invalidManifest + case missingLauncher + + var errorDescription: String? { + switch self { + case .missingManifest: + "language-server.json is missing." + case .invalidManifest: + "language-server.json contains invalid metadata." + case .missingLauncher: + "The Intelephense launcher is missing." + } + } +} + +enum MacPluginLanguageServerPackageValidator { + static func validate( + packageAt packageURL: URL, + pluginManifest: PluginManifest, + fileManager: FileManager = .default + ) throws { + guard pluginManifest.id == OfficialPluginCatalog.phpPluginID else { return } + let manifestURL = packageURL.appendingPathComponent("language-server.json") + guard let data = try? Data(contentsOf: manifestURL) else { + throw MacPluginLanguageServerPackageValidationError.missingManifest + } + let languageServerManifest: MacPluginLanguageServerManifest + do { + languageServerManifest = try JSONDecoder().decode( + MacPluginLanguageServerManifest.self, + from: data + ) + } catch { + throw MacPluginLanguageServerPackageValidationError.invalidManifest + } + guard languageServerManifest.schemaVersion == 1, + languageServerManifest.pluginID == pluginManifest.id.rawValue, + languageServerManifest.toolID == "intelephense", + !languageServerManifest.version.isEmpty, + languageServerManifest.archiveURL.scheme?.lowercased() == "https", + languageServerManifest.archiveURL.host != nil, + languageServerManifest.archiveFormat == "tarGzip", + isSafeRelativePath(languageServerManifest.archiveRoot), + isSafeRelativePath(languageServerManifest.entrypoint), + isSafeRelativePath(languageServerManifest.license), + languageServerManifest.archiveSHA256.count == 64, + languageServerManifest.archiveSHA256.allSatisfy({ $0.isHexDigit }), + case .nativeBundle = pluginManifest.entrypoint.kind, + let bundlePath = pluginManifest.entrypoint.bundlePath else { + throw MacPluginLanguageServerPackageValidationError.invalidManifest + } + let launcherURL = packageURL + .appendingPathComponent(bundlePath, isDirectory: true) + .appendingPathComponent("Contents/Resources/LanguageServers/php/bin/intelephense") + guard fileManager.isExecutableFile(atPath: launcherURL.path) else { + throw MacPluginLanguageServerPackageValidationError.missingLauncher + } + } + + private static func isSafeRelativePath(_ value: String) -> Bool { + !value.isEmpty + && !value.hasPrefix("/") + && !value.split(separator: "/", omittingEmptySubsequences: false).contains("..") + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift index fd4444774..86857c042 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift @@ -10,6 +10,8 @@ final class MacPluginManager: PluginManaging { private let launchMode: ModuleLaunchMode private let managedBuiltInPlugins: [PluginManifest] private let activeNativePluginIDs: Set + private let packageDownloader: any MacPluginPackageDownloading + private let hostVersion: PluginVersion private var installedPlugins: [PluginID: InstalledPluginPackage] private var restartRequiredPluginIDs: Set = [] private(set) var issues: [PluginManagementIssue] @@ -20,13 +22,17 @@ final class MacPluginManager: PluginManaging { configurationStore: MacModuleConfigurationStore, launchMode: ModuleLaunchMode, startup: MacPluginStartupResult, - managedBuiltInPlugins: [PluginManifest] = [] + managedBuiltInPlugins: [PluginManifest] = [], + packageDownloader: any MacPluginPackageDownloading = MacPluginPackageDownloader(), + hostVersion: PluginVersion = BuiltInPluginCatalog.hostVersion ) { self.packageStore = packageStore self.moduleRuntime = moduleRuntime self.configurationStore = configurationStore self.launchMode = launchMode self.managedBuiltInPlugins = managedBuiltInPlugins.sorted { $0.id < $1.id } + self.packageDownloader = packageDownloader + self.hostVersion = hostVersion activeNativePluginIDs = Set(startup.activeNativeManifests.map(\.id)) installedPlugins = Dictionary( uniqueKeysWithValues: startup.installedPlugins.map { ($0.manifest.id, $0) } @@ -96,6 +102,48 @@ final class MacPluginManager: PluginManaging { } } + func download(pluginID: PluginID) async throws { + guard pluginID == OfficialPluginCatalog.phpPluginID else { + throw PluginManagerError.onlineDownloadUnavailable(pluginID) + } + guard installedPlugins[pluginID] == nil else { + throw PluginManagerError.pluginAlreadyInstalled(pluginID) + } + let downloaded = try await packageDownloader.download( + pluginID: pluginID, + hostVersion: hostVersion + ) + defer { try? FileManager.default.removeItem(at: downloaded.temporaryDirectory) } + let installed = try packageStore.installPackage( + from: downloaded.packageURL, + deferActivationUntilRestart: true, + // The action is only exposed when no valid installation exists. + // Allow replacement so an older or unreadable package directory + // can be repaired with the package that carries Intelephense. + replaceExisting: true + ) + restartRequiredPluginIDs.insert(installed.manifest.id) + try refreshInstalledPlugins() + } + + func reinstall(pluginID: PluginID) async throws { + guard installedPlugins[pluginID] != nil else { + throw PluginManagerError.unknownPlugin(pluginID) + } + let downloaded = try await packageDownloader.download( + pluginID: pluginID, + hostVersion: hostVersion + ) + defer { try? FileManager.default.removeItem(at: downloaded.temporaryDirectory) } + let installed = try packageStore.installPackage( + from: downloaded.packageURL, + deferActivationUntilRestart: true, + replaceExisting: true + ) + restartRequiredPluginIDs.insert(installed.manifest.id) + try refreshInstalledPlugins() + } + func installPackage(at packageURL: URL) throws { let installed = try packageStore.installPackage( from: packageURL, @@ -216,12 +264,16 @@ final class MacPluginManager: PluginManaging { enum PluginManagerError: Error, Equatable, LocalizedError { case unknownPlugin(PluginID) + case onlineDownloadUnavailable(PluginID) + case pluginAlreadyInstalled(PluginID) case requiredPluginCannotBeDisabled(PluginID) case requiredPluginCannotBeUninstalled(PluginID) var errorDescription: String? { switch self { case .unknownPlugin(let id): "Plugin \(id) is not installed." + case .onlineDownloadUnavailable(let id): "Online download is not available for plugin \(id)." + case .pluginAlreadyInstalled(let id): "Plugin \(id) is already installed." case .requiredPluginCannotBeDisabled(let id): "Required plugin \(id) cannot be disabled." case .requiredPluginCannotBeUninstalled(let id): "Required plugin \(id) cannot be uninstalled." } diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageDownloader.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageDownloader.swift new file mode 100644 index 000000000..9f414f1fa --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageDownloader.swift @@ -0,0 +1,225 @@ +import Foundation +import LitheModuleAPI + +struct MacDownloadedPluginPackage { + let packageURL: URL + let temporaryDirectory: URL +} + +protocol MacPluginPackageDownloading { + func download( + pluginID: PluginID, + hostVersion: PluginVersion + ) async throws -> MacDownloadedPluginPackage +} + +enum MacPluginPackageDownloadError: Error, Equatable, LocalizedError { + case unsupportedPlugin(PluginID) + case invalidEndpoint + case httpStatus(Int) + case invalidArchive + case invalidLanguageServerPackage(String) + case extractionFailed(String) + + var errorDescription: String? { + switch self { + case .unsupportedPlugin(let pluginID): + return "Online download is not available for plugin \(pluginID)." + case .invalidEndpoint: + return "The official plugin download endpoint is invalid." + case .httpStatus(let status): + return "The official plugin server returned HTTP \(status)." + case .invalidArchive: + return "The downloaded plugin archive does not contain a valid plugin package." + case .invalidLanguageServerPackage(let detail): + return "The downloaded PHP plugin does not contain a valid language-server package: \(detail)" + case .extractionFailed(let detail): + return "The downloaded plugin archive could not be extracted: \(detail)" + } + } +} + +struct MacPluginDistributionConfiguration: Equatable, Sendable { + enum Channel: String, Sendable { + case stable + case preview + } + + let releaseBaseURL: URL + let channel: Channel + let architecture: String + let releaseVersion: PluginVersion + + init( + releaseBaseURL: URL = URL(string: "https://github.com/1lck/Lithe-IDEA/releases/download")!, + channel: Channel = MacPluginDistributionConfiguration.defaultChannel, + architecture: String = MacPluginDistributionConfiguration.currentArchitecture, + releaseVersion: PluginVersion = MacPluginDistributionConfiguration.defaultReleaseVersion + ) { + self.releaseBaseURL = releaseBaseURL + self.channel = channel + self.architecture = architecture + self.releaseVersion = releaseVersion + } + + static let currentArchitecture: String = { + #if arch(arm64) + return "arm64" + #elseif arch(x86_64) + return "x86_64" + #else + return "unknown" + #endif + }() + + static let defaultChannel: Channel = { + let value = Bundle.main.object(forInfoDictionaryKey: "LitheUpdateChannel") as? String + return value == Channel.preview.rawValue ? .preview : .stable + }() + + static let defaultReleaseVersion: PluginVersion = { + guard let value = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, + let version = PluginVersion(value) else { + return BuiltInPluginCatalog.hostVersion + } + return version + }() + + func archiveURL(pluginID: PluginID) throws -> URL { + guard pluginID == OfficialPluginCatalog.phpPluginID, + architecture == "arm64" || architecture == "x86_64" else { + throw MacPluginPackageDownloadError.unsupportedPlugin(pluginID) + } + let releaseTag = channel == .preview ? "preview-\(releaseVersion)" : "v\(releaseVersion)" + let archiveName = "Lithe-PHP-Support-\(releaseVersion)-\(architecture).zip" + guard releaseBaseURL.scheme?.lowercased() == "https", + releaseBaseURL.host != nil else { + throw MacPluginPackageDownloadError.invalidEndpoint + } + return releaseBaseURL + .appendingPathComponent(releaseTag, isDirectory: true) + .appendingPathComponent(archiveName, isDirectory: false) + } +} + +final class MacPluginPackageDownloader: MacPluginPackageDownloading { + private let configuration: MacPluginDistributionConfiguration + private let session: URLSession + private let fileManager: FileManager + private let temporaryDirectory: URL + + init( + configuration: MacPluginDistributionConfiguration = MacPluginDistributionConfiguration(), + session: URLSession = .shared, + fileManager: FileManager = .default, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) { + self.configuration = configuration + self.session = session + self.fileManager = fileManager + self.temporaryDirectory = temporaryDirectory + } + + func download( + pluginID: PluginID, + hostVersion _: PluginVersion + ) async throws -> MacDownloadedPluginPackage { + let archiveURL = try configuration.archiveURL(pluginID: pluginID) + try Task.checkCancellation() + let request = URLRequest(url: archiveURL, cachePolicy: .reloadIgnoringLocalCacheData) + let (archiveURLOnDisk, response): (URL, URLResponse) + do { + (archiveURLOnDisk, response) = try await session.download(for: request) + } catch { + throw error + } + defer { try? fileManager.removeItem(at: archiveURLOnDisk) } + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) else { + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + throw MacPluginPackageDownloadError.httpStatus(status) + } + try Task.checkCancellation() + + let extractionRoot = temporaryDirectory + .appendingPathComponent("lithe-plugin-download-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: extractionRoot, withIntermediateDirectories: true) + do { + try extract(archiveURLOnDisk, into: extractionRoot) + let packageURL = try findPackage(in: extractionRoot, pluginID: pluginID) + do { + let manifest = try JSONDecoder().decode( + PluginManifest.self, + from: Data(contentsOf: packageURL.appendingPathComponent("plugin.json")) + ) + try MacPluginLanguageServerPackageValidator.validate( + packageAt: packageURL, + pluginManifest: manifest + ) + } catch let error as MacPluginLanguageServerPackageValidationError { + throw MacPluginPackageDownloadError.invalidLanguageServerPackage( + error.localizedDescription + ) + } catch { + throw MacPluginPackageDownloadError.invalidLanguageServerPackage( + "plugin.json could not be decoded" + ) + } + return MacDownloadedPluginPackage( + packageURL: packageURL, + temporaryDirectory: extractionRoot + ) + } catch { + try? fileManager.removeItem(at: extractionRoot) + throw error + } + } + + private func extract(_ archiveURL: URL, into directory: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") + process.arguments = ["-x", "-k", archiveURL.path, directory.path] + let errorPipe = Pipe() + process.standardError = errorPipe + do { + try process.run() + } catch { + throw MacPluginPackageDownloadError.extractionFailed(error.localizedDescription) + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let detail = String( + data: errorPipe.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + )?.trimmingCharacters(in: .whitespacesAndNewlines) + throw MacPluginPackageDownloadError.extractionFailed(detail ?? "ditto exited with status \(process.terminationStatus)") + } + } + + private func findPackage(in root: URL, pluginID: PluginID) throws -> URL { + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { + throw MacPluginPackageDownloadError.invalidArchive + } + var matches: [URL] = [] + for case let candidate as URL in enumerator { + guard candidate.lastPathComponent == "plugin.json", + let isDirectory = try? candidate.deletingLastPathComponent() + .resourceValues(forKeys: [.isDirectoryKey]).isDirectory, + isDirectory == true else { continue } + let packageURL = candidate.deletingLastPathComponent().standardizedFileURL + guard let data = try? Data(contentsOf: candidate), + let manifest = try? JSONDecoder().decode(PluginManifest.self, from: data), + manifest.id == pluginID else { continue } + matches.append(packageURL) + } + guard matches.count == 1 else { + throw MacPluginPackageDownloadError.invalidArchive + } + return matches[0] + } + +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift index 02b50839e..ca22fcf71 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift @@ -179,6 +179,11 @@ final class MacPluginPackageStore { } _ = try ValidatedPluginCatalog(manifests: [manifest], hostVersion: hostVersion) try verifier.verify(packageAt: packageURL, manifest: manifest) + try MacPluginLanguageServerPackageValidator.validate( + packageAt: packageURL, + pluginManifest: manifest, + fileManager: fileManager + ) let candidate = InstalledPluginPackage( manifest: manifest, installation: record, @@ -235,6 +240,11 @@ final class MacPluginPackageStore { hostVersion: hostVersion ) try verifier.verify(packageAt: packageURL, manifest: manifest) + try MacPluginLanguageServerPackageValidator.validate( + packageAt: packageURL, + pluginManifest: manifest, + fileManager: fileManager + ) installed.append(InstalledPluginPackage( manifest: manifest, installation: PluginInstallationRecord( @@ -265,7 +275,8 @@ final class MacPluginPackageStore { @discardableResult func installPackage( from sourceURL: URL, - deferActivationUntilRestart: Bool = false + deferActivationUntilRestart: Bool = false, + replaceExisting: Bool = false ) throws -> InstalledPluginPackage { let sourceManifest = try loadManifest(at: sourceURL) guard !Self.retiredPluginIDs.contains(sourceManifest.id) else { @@ -286,6 +297,18 @@ final class MacPluginPackageStore { throw PluginPackageStoreError.manifestDoesNotMatchInstallation } try verifier.verify(packageAt: stagedURL, manifest: manifest) + do { + try MacPluginLanguageServerPackageValidator.validate( + packageAt: stagedURL, + pluginManifest: manifest, + fileManager: fileManager + ) + } catch { + throw PluginPackageStoreError.invalidInstalledPlugin( + manifest.id, + error.localizedDescription + ) + } let pluginDirectory = rootURL.appendingPathComponent(manifest.id.rawValue, isDirectory: true) let versionsDirectory = pluginDirectory.appendingPathComponent("versions", isDirectory: true) @@ -294,22 +317,41 @@ final class MacPluginPackageStore { pluginDirectory: pluginDirectory, version: manifest.version ) - guard !fileManager.fileExists(atPath: destination.path) else { + let destinationExists = fileManager.fileExists(atPath: destination.path) + guard !destinationExists || replaceExisting else { throw PluginPackageStoreError.versionAlreadyInstalled(manifest.version) } let existingRecord = try? installationRecord(at: pluginDirectory) - try fileManager.moveItem(at: stagedURL, to: destination) - shouldRemoveStaging = false + let backupURL = destinationExists + ? stagingRoot.appendingPathComponent("backup-\(UUID().uuidString)", isDirectory: true) + : nil + if let backupURL { + try fileManager.moveItem(at: destination, to: backupURL) + } + do { + try fileManager.moveItem(at: stagedURL, to: destination) + shouldRemoveStaging = false + } catch { + if let backupURL { + try? fileManager.moveItem(at: backupURL, to: destination) + } + throw error + } do { let record = PluginInstallationRecord( pluginID: manifest.id, activeVersion: manifest.version, - previousVersion: existingRecord?.activeVersion, + previousVersion: existingRecord?.activeVersion == manifest.version + ? existingRecord?.previousVersion + : existingRecord?.activeVersion, origin: .marketplace, status: deferActivationUntilRestart ? .updateStaged : .installed ) try write(record, to: pluginDirectory.appendingPathComponent("installation.json")) + if let backupURL { + try? fileManager.removeItem(at: backupURL) + } return InstalledPluginPackage( manifest: manifest, installation: record, @@ -317,6 +359,9 @@ final class MacPluginPackageStore { ) } catch { try? fileManager.removeItem(at: destination) + if let backupURL { + try? fileManager.moveItem(at: backupURL, to: destination) + } throw error } } @@ -340,6 +385,12 @@ final class MacPluginPackageStore { guard manifest.id == pluginID, manifest.version == previousVersion else { throw PluginPackageStoreError.manifestDoesNotMatchInstallation } + try verifier.verify(packageAt: previousPackageURL, manifest: manifest) + try MacPluginLanguageServerPackageValidator.validate( + packageAt: previousPackageURL, + pluginManifest: manifest, + fileManager: fileManager + ) let restored = PluginInstallationRecord( pluginID: pluginID, activeVersion: previousVersion, diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift index d864e619f..d1cced6e2 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift @@ -6,17 +6,20 @@ import Foundation struct MacRuntimeToolDiscovery: RuntimeToolDiscovery { private let homeDirectoryURL: URL private let resourceDirectoryURL: URL? + private let pluginToolRoots: [URL] private let isExecutable: @Sendable (URL) -> Bool init( homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, resourceDirectoryURL: URL? = Bundle.main.resourceURL, + pluginToolRoots: [URL] = [], isExecutable: @escaping @Sendable (URL) -> Bool = { FileManager.default.isExecutableFile(atPath: $0.path) } ) { self.homeDirectoryURL = homeDirectoryURL.standardizedFileURL self.resourceDirectoryURL = resourceDirectoryURL?.standardizedFileURL + self.pluginToolRoots = pluginToolRoots.map(\.standardizedFileURL) self.isExecutable = isExecutable } @@ -49,6 +52,16 @@ struct MacRuntimeToolDiscovery: RuntimeToolDiscovery { ) } + if command == "intelephense" { + for root in pluginToolRoots { + add( + root.appendingPathComponent("bin/intelephense"), + source: .bundled, + detail: "PHP Support plugin" + ) + } + } + // Project-local toolchains are preferred because they are reproducible // and do not alter the user's global environment. if let projectURL { @@ -173,6 +186,13 @@ struct MacRuntimeToolDiscovery: RuntimeToolDiscovery { summary: "A Java DAP adapter was not found.", recovery: "Reinstall Lithe's bundled Java language and Debug Adapter resources." ) + case "intelephense": + 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." + ) default: return RuntimeToolGuidance( command: command, diff --git a/macos/Sources/Lithe/Views/App/PluginManagementView.swift b/macos/Sources/Lithe/Views/App/PluginManagementView.swift index 4e6fa6720..3eaabf041 100644 --- a/macos/Sources/Lithe/Views/App/PluginManagementView.swift +++ b/macos/Sources/Lithe/Views/App/PluginManagementView.swift @@ -10,6 +10,8 @@ struct PluginManagementView: View { @State private var searchText = "" @State private var selectedPluginID: PluginID? @State private var hoveredPluginID: PluginID? + @State private var isManagingPackage = false + @State private var confirmingPHPUninstall = false @AppStorage("lithe.settings.pluginListWidth") private var pluginListWidth = 320.0 private var pendingEnabledStates: [PluginID: Bool] { settingsState.pendingPluginEnabledStates } @@ -69,6 +71,18 @@ struct PluginManagementView: View { .onAppear { selectedPluginID = installedPlugins.first?.id } + .confirmationDialog( + LocalizedStringKey("Uninstall PHP Support?"), + isPresented: $confirmingPHPUninstall, + titleVisibility: .visible + ) { + Button(LocalizedStringKey("Uninstall"), role: .destructive) { + performPackageAction { await model.uninstallPHPPlugin() } + } + Button(LocalizedStringKey("Cancel"), role: .cancel) {} + } message: { + Text(LocalizedStringKey("PHP language support will be removed after restarting Lithe.")) + } } private var header: some View { @@ -205,7 +219,25 @@ struct PluginManagementView: View { } .buttonStyle(.borderedProminent) .tint(LitheTheme.accent) - .disabled(isApplyingChanges || plugin.isRequired) + .disabled(isApplyingChanges || isManagingPackage || plugin.isRequired) + if plugin.id == OfficialPluginCatalog.phpPluginID { + Button { + performPackageAction { await model.reinstallPHPPlugin() } + } label: { + if isManagingPackage { + ProgressView().controlSize(.small) + } else { + Text(LocalizedStringKey("Reinstall")) + } + } + .buttonStyle(.bordered) + .disabled(isApplyingChanges || isManagingPackage) + Button(LocalizedStringKey("Uninstall")) { + confirmingPHPUninstall = true + } + .buttonStyle(.bordered) + .disabled(isApplyingChanges || isManagingPackage || plugin.isRequired) + } }.padding(24) Text(LocalizedStringKey("Overview")).font(.system(size: 15, weight: .semibold)).padding(.horizontal, 24) VStack(alignment: .leading, spacing: 12) { @@ -227,13 +259,27 @@ struct PluginManagementView: View { VStack(alignment: .leading, spacing: 16) { Text(LocalizedStringKey(manifest.displayName)) .font(.system(size: 22, weight: .bold)) - Text(LocalizedStringKey("Install PHP Support from a signed plugin package.")) + Text(LocalizedStringKey("Download PHP Support from the official plugin release.")) .foregroundStyle(LitheTheme.secondaryText) - Button(LocalizedStringKey("Install Plugin from Disk…")) { - model.installPHPPluginPackage() + HStack(spacing: 10) { + Button { + performPackageAction { await model.downloadPHPPlugin() } + } label: { + if isManagingPackage { + ProgressView().controlSize(.small) + } else { + Text(LocalizedStringKey("Download and Install")) + } + } + .buttonStyle(.borderedProminent) + .tint(LitheTheme.accent) + .disabled(isApplyingChanges || isManagingPackage) + Button(LocalizedStringKey("Install Plugin from Disk…")) { + model.installPHPPluginPackage() + } + .buttonStyle(.bordered) + .disabled(isApplyingChanges || isManagingPackage) } - .buttonStyle(.borderedProminent) - .disabled(isApplyingChanges) Spacer() } .padding(24) @@ -295,6 +341,15 @@ struct PluginManagementView: View { } } + private func performPackageAction(_ action: @escaping @MainActor () async -> Void) { + guard !isManagingPackage else { return } + isManagingPackage = true + Task { @MainActor in + await action() + isManagingPackage = false + } + } + private var phpPresentation: PluginPresentation { PluginPresentation( systemImage: "globe", diff --git a/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift b/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift index d5550aee1..6395e9b46 100644 --- a/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift +++ b/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheModuleAPI struct LSPControlCenterView: View { @EnvironmentObject private var model: AppModel @@ -11,7 +12,10 @@ struct LSPControlCenterView: View { ScrollView { VStack(alignment: .leading, spacing: 16) { projectSummary - if projectLanguageServers.isEmpty { + if let phpNotice = phpNotice { + phpPluginNotice(phpNotice) + } + if projectLanguageServers.isEmpty && phpNotice == nil { emptyState } else { ForEach(projectLanguageServers) { descriptor in @@ -129,11 +133,77 @@ struct LSPControlCenterView: View { private var projectLanguageServers: [LanguageProviderDescriptor] { model.languageProviderCatalog.descriptors .filter { $0.capabilities.contains(.languageServer) && $0.languageServerLaunch != nil } + .filter { descriptor in + descriptor.id != "php" || phpPluginIsReady + } .filter { descriptor in model.projectFiles.contains { descriptor.handles(fileURL: $0) } } } + private var phpPluginSnapshot: PluginManagementSnapshot? { + model.pluginSnapshots.first { $0.id == OfficialPluginCatalog.phpPluginID } + } + + private var phpPluginIsReady: Bool { + guard let snapshot = phpPluginSnapshot else { return false } + return snapshot.isEnabled && !snapshot.requiresRestart && !snapshot.isQuarantined + } + + private var hasPHPProjectFiles: Bool { + model.projectFiles.contains { fileURL in + let pathExtension = fileURL.pathExtension.lowercased() + return ["php", "phtml"].contains(pathExtension) + || fileURL.lastPathComponent.lowercased() == "composer.json" + } + } + + private var phpNotice: String? { + guard hasPHPProjectFiles, !phpPluginIsReady else { return nil } + guard let snapshot = phpPluginSnapshot else { + return usesChinese + ? "检测到 PHP 项目,请先在插件管理中下载并安装 PHP 支持。" + : "This PHP project needs PHP Support. Download and install it from Plugin Management." + } + if snapshot.requiresRestart { + return usesChinese + ? "PHP 支持已安装,重启 Lithe 后才能在这里控制语言服务器。" + : "PHP Support is installed. Restart Lithe before controlling its language server here." + } + if snapshot.isQuarantined { + return usesChinese + ? "PHP 支持因上次启动异常被隔离,请先在插件管理中重新启用。" + : "PHP Support was quarantined after its previous session. Re-enable it from Plugin Management." + } + return usesChinese + ? "PHP 支持插件已禁用,请先在插件管理中启用。" + : "PHP Support is disabled. Enable it from Plugin Management first." + } + + private func phpPluginNotice(_ message: String) -> some View { + VStack(alignment: .leading, spacing: 10) { + Label(message, systemImage: "puzzlepiece.extension") + .font(.system(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + Button(usesChinese ? "打开插件管理" : "Open Plugin Management") { + model.showSettings(category: .plugins) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius) + .fill(LitheTheme.settingsSurface) + ) + .overlay { + RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius) + .stroke(LitheTheme.divider, lineWidth: 1) + } + } + private func serverStatus(for descriptor: LanguageProviderDescriptor) -> LSPServerStatus { LSPControlCenterPresenter.serverStatus( isDisabled: model.isLanguageServerDisabledInCurrentWorkspace(providerID: descriptor.id), diff --git a/macos/Tests/LitheTests/AppLocalizationTests.swift b/macos/Tests/LitheTests/AppLocalizationTests.swift index 4856d363f..983cd8ecd 100644 --- a/macos/Tests/LitheTests/AppLocalizationTests.swift +++ b/macos/Tests/LitheTests/AppLocalizationTests.swift @@ -208,6 +208,9 @@ struct AppLocalizationTests { #expect(translations["PHP Support"] == "PHP 支持") #expect(translations["Installed (%lld of %lld enabled)"] == "已安装(%lld / %lld 个已启用)") + #expect(translations["Download and Install"] == "下载并安装") + #expect(translations["Reinstall"] == "重新安装") + #expect(translations["Open Plugin Management"] == "打开插件管理") } @Test diff --git a/macos/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift b/macos/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift index ca55628f8..f45949ff3 100644 --- a/macos/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift +++ b/macos/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift @@ -149,6 +149,57 @@ struct LanguageProviderCatalogSourceTests { #expect(provider.languageServerLaunch == nil) } + @Test + func installedPackageKeepsSharedLaunchMetadataForItsControlCenter() throws { + let source = PluginLanguageProviderCatalogSource( + base: RustLanguageProviderCatalogSource(loader: CatalogPayloadLoader( + isAvailable: true, + data: Data(""" + { + "version": 2, + "origin": "builtin", + "providers": [{ + "id": "php", + "displayName": "PHP", + "fileExtensions": ["php"], + "fileNames": [], + "fileNamePrefixes": [], + "capabilities": ["languageServer"], + "activationPolicy": "onDemand", + "languageId": "php", + "languageIdsByExtension": {}, + "languageIdsByFileName": {}, + "languageServerLaunch": { + "executableNames": ["intelephense"], + "arguments": ["--stdio"], + "environment": {} + }, + "languageServerInstallation": { + "homebrewFormula": "intelephense", + "officialDownloadURL": "https://intelephense.com/" + } + }], + "diagnostics": [] + } + """.utf8) + )), + languageSupports: [LanguageSupportDeclaration( + id: "php", + displayName: "PHP", + fileExtensions: ["php"], + languageServerModuleID: .languageServerExtension("php") + )] + ) + + let provider = try #require(source.load().catalog.provider( + for: URL(fileURLWithPath: "/tmp/index.php") + )) + + #expect(provider.languageServerLaunch?.executableNames == ["intelephense"]) + #expect(provider.languageServerLaunch?.arguments == ["--stdio"]) + #expect(provider.languageServerInstallation == nil) + } + @Test func syntaxOnlyBundledLanguagesDoNotAdvertiseAnUnimplementedServer() throws { let source = PluginLanguageProviderCatalogSource( diff --git a/macos/Tests/LitheTests/MacPluginPackageDownloaderTests.swift b/macos/Tests/LitheTests/MacPluginPackageDownloaderTests.swift new file mode 100644 index 000000000..392259648 --- /dev/null +++ b/macos/Tests/LitheTests/MacPluginPackageDownloaderTests.swift @@ -0,0 +1,43 @@ +import Foundation +import LitheModuleAPI +import Testing +@testable import Lithe + +struct MacPluginPackageDownloaderTests { + @Test + func officialPHPArchiveURLUsesTheAppReleaseVersionAndChannel() throws { + let configuration = MacPluginDistributionConfiguration( + releaseBaseURL: URL(string: "https://downloads.example.test/releases")!, + channel: .stable, + architecture: "arm64", + releaseVersion: PluginVersion(major: 0, minor: 5, patch: 8) + ) + + let url = try configuration.archiveURL(pluginID: OfficialPluginCatalog.phpPluginID) + + #expect(url.absoluteString == "https://downloads.example.test/releases/v0.5.8/Lithe-PHP-Support-0.5.8-arm64.zip") + } + + @Test + func previewPHPArchiveURLUsesTheRollingPreviewTag() throws { + let configuration = MacPluginDistributionConfiguration( + releaseBaseURL: URL(string: "https://downloads.example.test/releases")!, + channel: .preview, + architecture: "x86_64", + releaseVersion: PluginVersion(major: 0, minor: 3, patch: 0) + ) + + let url = try configuration.archiveURL(pluginID: OfficialPluginCatalog.phpPluginID) + + #expect(url.absoluteString == "https://downloads.example.test/releases/preview-0.3.0/Lithe-PHP-Support-0.3.0-x86_64.zip") + } + + @Test + func unsupportedPluginCannotBeMappedToThePHPArchive() { + let configuration = MacPluginDistributionConfiguration(architecture: "arm64") + + #expect(throws: MacPluginPackageDownloadError.unsupportedPlugin(PluginID("dev.example.plugin"))) { + _ = try configuration.archiveURL(pluginID: PluginID("dev.example.plugin")) + } + } +} diff --git a/macos/Tests/LitheTests/MacRuntimeToolDiscoveryTests.swift b/macos/Tests/LitheTests/MacRuntimeToolDiscoveryTests.swift new file mode 100644 index 000000000..a17ae0e56 --- /dev/null +++ b/macos/Tests/LitheTests/MacRuntimeToolDiscoveryTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing +@testable import Lithe + +struct MacRuntimeToolDiscoveryTests { + @Test + func prefersTheExecutableOwnedByTheInstalledPHPPlugin() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-php-tool-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let executable = root.appendingPathComponent("bin/intelephense") + try FileManager.default.createDirectory( + at: executable.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + #expect(FileManager.default.createFile(atPath: executable.path, contents: Data())) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: executable.path + ) + + let discovery = MacRuntimeToolDiscovery( + homeDirectoryURL: root, + resourceDirectoryURL: nil, + pluginToolRoots: [root] + ) + let candidates = discovery.candidates( + for: "intelephense", + projectURL: nil, + environment: [:] + ) + + #expect(candidates.first?.executableURL == executable.standardizedFileURL) + #expect(candidates.first?.source == .bundled) + #expect(candidates.first?.detail == "PHP Support plugin") + } +} diff --git a/macos/Tests/LitheTests/PluginPackageStoreTests.swift b/macos/Tests/LitheTests/PluginPackageStoreTests.swift index 1e4182960..120b5d859 100644 --- a/macos/Tests/LitheTests/PluginPackageStoreTests.swift +++ b/macos/Tests/LitheTests/PluginPackageStoreTests.swift @@ -228,6 +228,34 @@ struct PluginPackageStoreTests { #expect(installed.installation.previousVersion == nil) } + @Test + func reinstallCanReplaceTheActiveVersionOnlyAfterValidation() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-plugin-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let version = PluginVersion(major: 0, minor: 3, patch: 0) + let store = MacPluginPackageStore( + rootURL: root.appendingPathComponent("installed", isDirectory: true), + verifier: TestPluginSignatureVerifier() + ) + _ = try store.installPackage(from: makePackage(root: root, name: "first", version: version)) + let replacement = try makePackage(root: root, name: "replacement", version: version) + + #expect(throws: PluginPackageStoreError.versionAlreadyInstalled(version)) { + _ = try store.installPackage(from: replacement) + } + let reinstalled = try store.installPackage( + from: replacement, + deferActivationUntilRestart: true, + replaceExisting: true + ) + + #expect(reinstalled.installation.activeVersion == version) + #expect(reinstalled.installation.previousVersion == nil) + #expect(reinstalled.installation.status == .updateStaged) + #expect(try store.installedPlugins().first?.manifest.version == version) + } + @Test func requiredPluginCannotBeUninstalled() throws { let root = FileManager.default.temporaryDirectory @@ -283,6 +311,90 @@ struct PluginPackageStoreTests { #expect(try store.installedPlugins().isEmpty) } + @Test + func uninstallRemovesThePHPPluginLanguageServerWithItsOwner() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-php-plugin-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = MacPluginPackageStore( + rootURL: root.appendingPathComponent("installed", isDirectory: true), + verifier: TestPluginSignatureVerifier() + ) + let package = try makePHPPluginPackage( + root: root, + name: "php", + version: BuiltInPluginCatalog.hostVersion + ) + + let installed = try store.installPackage(from: package) + let launcher = installed.packageURL + .appendingPathComponent("PhpSupport.bundle/Contents/Resources/LanguageServers/php/bin/intelephense") + #expect(FileManager.default.fileExists(atPath: launcher.path)) + + try store.stageUninstall(installed.manifest.id) + try store.prepareForLaunch() + + #expect(!FileManager.default.fileExists(atPath: installed.packageURL.path)) + #expect(try store.installedPlugins().isEmpty) + } + + @Test + func replacementRepairsALegacyPHPPackageWithoutItsLanguageServer() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-php-plugin-repair-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let installedRoot = root.appendingPathComponent("installed", isDirectory: true) + let store = MacPluginPackageStore( + rootURL: installedRoot, + verifier: TestPluginSignatureVerifier() + ) + let version = BuiltInPluginCatalog.hostVersion + let legacy = try makePHPPluginPackage(root: root, name: "legacy", version: version) + try FileManager.default.removeItem(at: legacy.appendingPathComponent("language-server.json")) + try FileManager.default.removeItem( + at: legacy.appendingPathComponent( + "PhpSupport.bundle/Contents/Resources/LanguageServers/php" + ) + ) + let legacyDestination = installedRoot + .appendingPathComponent(OfficialPluginCatalog.phpPluginID.rawValue, isDirectory: true) + .appendingPathComponent("versions", isDirectory: true) + .appendingPathComponent(version.description, isDirectory: true) + try FileManager.default.createDirectory( + at: legacyDestination.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.copyItem(at: legacy, to: legacyDestination) + let installationDirectory = legacyDestination + .deletingLastPathComponent() + .deletingLastPathComponent() + let record = PluginInstallationRecord( + pluginID: OfficialPluginCatalog.phpPluginID, + activeVersion: version, + origin: .marketplace + ) + try JSONEncoder().encode(record).write( + to: installationDirectory.appendingPathComponent("installation.json") + ) + + let replacement = try makePHPPluginPackage(root: root, name: "replacement", version: version) + let installed = try store.installPackage( + from: replacement, + deferActivationUntilRestart: true, + replaceExisting: true + ) + + #expect(installed.installation.status == .updateStaged) + #expect(FileManager.default.fileExists( + atPath: installed.packageURL.appendingPathComponent("language-server.json").path + )) + #expect(FileManager.default.fileExists( + atPath: installed.packageURL.appendingPathComponent( + "PhpSupport.bundle/Contents/Resources/LanguageServers/php/bin/intelephense" + ).path + )) + } + @MainActor @Test func interruptedPluginCodeLoadIsQuarantinedBeforeRetry() throws { @@ -449,6 +561,64 @@ struct PluginPackageStoreTests { ) return packageURL } + + private func makePHPPluginPackage( + root: URL, + name: String, + version: PluginVersion + ) throws -> URL { + let packageURL = root.appendingPathComponent("sources/\(name)", isDirectory: true) + let manifest = try #require(OfficialPluginCatalog.manifests.first { + $0.id == OfficialPluginCatalog.phpPluginID + }) + let bundleRoot = packageURL.appendingPathComponent( + "PhpSupport.bundle/Contents/Resources/LanguageServers/php/bin", + isDirectory: true + ) + try FileManager.default.createDirectory(at: bundleRoot, withIntermediateDirectories: true) + let launcher = bundleRoot.appendingPathComponent("intelephense") + #expect(FileManager.default.createFile(atPath: launcher.path, contents: Data())) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: launcher.path + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + var versionedManifest = manifest + versionedManifest = PluginManifest( + id: manifest.id, + displayName: manifest.displayName, + version: version, + hostCompatibility: manifest.hostCompatibility, + vendor: manifest.vendor, + entrypoint: manifest.entrypoint, + modules: manifest.modules, + languageSupports: manifest.languageSupports ?? [] + ) + try encoder.encode(versionedManifest).write( + to: packageURL.appendingPathComponent("plugin.json"), + options: .atomic + ) + let languageServerManifest = """ + { + "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" + } + """ + try Data(languageServerManifest.utf8).write( + to: packageURL.appendingPathComponent("language-server.json"), + options: .atomic + ) + return packageURL + } } private final class PluginStartupConfigurationStore: ModuleConfigurationStore, @unchecked Sendable { diff --git a/scripts/build-official-plugins.sh b/scripts/build-official-plugins.sh index 6bf159b83..369f3b35f 100755 --- a/scripts/build-official-plugins.sh +++ b/scripts/build-official-plugins.sh @@ -87,6 +87,14 @@ for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do rm -rf "$package_dir" mkdir -p "$executable_dir" cp "$manifest" "$package_dir/plugin.json" + if [[ "$package_id" == "dev.lithe.plugin.php-support" ]]; then + language_server_manifest="$plugin_source/language-server.json" + [[ -f "$language_server_manifest" ]] || { + print -u2 -- "PHP Support is missing its language-server manifest: $language_server_manifest" + exit 1 + } + cp "$language_server_manifest" "$package_dir/language-server.json" + fi cp "$info_plist" "$bundle_dir/Contents/Info.plist" "$SWIFT_COMPILER" \ @@ -102,6 +110,12 @@ for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do "${source_files[@]}" \ -o "$executable_dir/$executable_name" + if [[ "$package_id" == "dev.lithe.plugin.php-support" ]]; then + "$ROOT_DIR/scripts/prepare-php-language-server.sh" \ + --manifest "$plugin_source/language-server.json" \ + --output "$bundle_dir/Contents/Resources/LanguageServers/php" >&2 + fi + /usr/bin/codesign --force --sign "$SIGNING_IDENTITY" "$bundle_dir" done diff --git a/scripts/prepare-php-language-server.sh b/scripts/prepare-php-language-server.sh new file mode 100755 index 000000000..d9c83d884 --- /dev/null +++ b/scripts/prepare-php-language-server.sh @@ -0,0 +1,125 @@ +#!/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" < 0 ? options.resources.map((identifier) => { - // Runtime snapshots (including credential-bearing IDE MCP connections and JDT Maven settings) - // take this rejection route, never a content-hash reuse validator. + // Runtime snapshots and isolated PHP packaging resources take this + // rejection route, never a content-hash reuse validator: the pinned npm + // archive alone does not identify the generated, signed plugin package. if (excludedResources.some((resource) => resource.id === identifier)) { const excluded = excludedResources.find((resource) => resource.id === identifier); throw new Error(`Resource ${identifier} is isolated (${excluded.locations.join(", ")}): ${excluded.reason}; it cannot be reused across worktrees`); diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index b0fece767..4dd984dcd 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -87,6 +87,16 @@ try { assert.notEqual(refused.status, 0); assert.match(diagnostics(refused), /jdt-maven-settings.*cannot be reused/); }); + await test("PHP downloads and native plugin packages 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"]) { + assert.ok(!listed.stdout.includes(id)); + const refused = reuse(["--resource", id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), new RegExp(`${id}.*cannot be reused`)); + } + }); await testFailedBackupPreservesDestination(); await fs.mkdir(path.join(sourceRoot, "third_party", "jdtls"), { recursive: true }); await fs.writeFile( diff --git a/scripts/verify-official-plugins.sh b/scripts/verify-official-plugins.sh index b33a44125..8087e744d 100755 --- a/scripts/verify-official-plugins.sh +++ b/scripts/verify-official-plugins.sh @@ -27,5 +27,17 @@ PLUGIN_ROOT=$(scripts/build-official-plugins.sh \ plugins=("$PLUGIN_ROOT"/*(/N)) 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/PhpSupport.bundle/Contents/Resources/LanguageServers/php/bin/intelephense" + [[ -x "$php_launcher" ]] || { + print -u2 -- "PHP plugin Intelephense launcher is missing: $php_launcher" + exit 1 + } + /usr/bin/codesign --verify --deep --strict "$plugin/PhpSupport.bundle" + fi done print "Verified ${#plugins[@]} released official native plugin package(s)" diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 95bf19f7c..5fce253c3 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -46,17 +46,31 @@ "id": "official-plugin-packages", "path": ".build///OfficialPlugins", "reusable": false, - "locations": [".build///OfficialPlugins"], + "locations": [ + ".build///OfficialPlugins", + "/Lithe/Plugins/dev.lithe.plugin.php-support/" + ], "identity": "Native plugin output is tied to the host API, Swift toolchain, platform architecture and signing; no reliable worktree identity stamp", "reason": "Build each official plugin independently in every worktree and validate it with LitheOfficialPluginVerifier; do not copy or symlink it across worktrees." }, + { + "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//", "reusable": false, - "locations": ["/language-tools//"], - "identity": "User-installed language tools and lock files are mutable runtime state without a worktree identity stamp", - "reason": "Do not reuse user-managed language tools across worktrees; plugin lifecycle owns installation and cleanup." + "locations": [ + "/language-tools//", + "/Lithe/Plugins//versions//PhpSupport.bundle/Contents/Resources/LanguageServers/php" + ], + "identity": "Plugin-owned language tools are mutable runtime state bound to an installed plugin version and signing identity, not a worktree build stamp", + "reason": "Do not reuse plugin-owned language tools across worktrees; plugin lifecycle owns installation, replacement and cleanup." }, { "id": "phpunit-fixture", diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 822edccf1..2e75a51bc 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -2702,7 +2702,10 @@ "evidence": [ "Plugins/mac/Official/PhpSupport", "scripts/official-plugin-distribution.mjs", - "macos/Sources/Lithe/Platform/MacOS/Plugins" + "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" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -2719,7 +2722,7 @@ "verificationStatus": "pending" }, "owner": "PHP Support", - "verification": "在干净安装上确认没有 PHP 插件运行时;显式安装并启用后验证补全、诊断,安装中取消、运行中禁用、关闭工作区和卸载后确认无插件进程,用户工具保留。 Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。" + "verification": "macOS 在干净安装上打开插件管理,确认可从当前发行渠道下载并安装 PHP、失败时可从磁盘导入;安装后重启,在 LSP 界面只控制当前项目开关和状态,验证重装、卸载均在重启后生效且用户工具保留。Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。" }, { "id": "php-run-test", From c4311ce5f4acba887ed7882584392ab1edf7dcd1 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 30 Sep 2026 20:52:12 +0800 Subject: [PATCH 2/5] fix(macos): bound PHP plugin archive extraction --- .../Plugins/MacPluginPackageDownloader.swift | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageDownloader.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageDownloader.swift index 9f414f1fa..90bcc5291 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageDownloader.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageDownloader.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation import LitheModuleAPI @@ -103,6 +104,8 @@ struct MacPluginDistributionConfiguration: Equatable, Sendable { } final class MacPluginPackageDownloader: MacPluginPackageDownloading { + private static let extractionTimeout: TimeInterval = 5 * 60 + private static let extractionPollInterval: TimeInterval = 0.05 private let configuration: MacPluginDistributionConfiguration private let session: URLSession private let fileManager: FileManager @@ -186,7 +189,26 @@ final class MacPluginPackageDownloader: MacPluginPackageDownloading { } catch { throw MacPluginPackageDownloadError.extractionFailed(error.localizedDescription) } - process.waitUntilExit() + // `ditto` is an external process, so waiting without a local deadline + // would leave a cancelled or wedged download task alive indefinitely. + // Poll on a bounded interval and terminate the process before + // propagating cancellation or timeout to the caller. + let deadline = Date().addingTimeInterval(Self.extractionTimeout) + while process.isRunning { + do { + try Task.checkCancellation() + } catch { + terminateExtractionProcess(process) + throw error + } + guard Date() < deadline else { + terminateExtractionProcess(process) + throw MacPluginPackageDownloadError.extractionFailed( + "ditto did not finish within \(Int(Self.extractionTimeout)) seconds" + ) + } + Thread.sleep(forTimeInterval: Self.extractionPollInterval) + } guard process.terminationStatus == 0 else { let detail = String( data: errorPipe.fileHandleForReading.readDataToEndOfFile(), @@ -196,6 +218,22 @@ final class MacPluginPackageDownloader: MacPluginPackageDownloading { } } + private func terminateExtractionProcess(_ process: Process) { + guard process.isRunning else { return } + process.terminate() + let deadline = Date().addingTimeInterval(1) + while process.isRunning, Date() < deadline { + Thread.sleep(forTimeInterval: Self.extractionPollInterval) + } + if process.isRunning { + process.interrupt() + } + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + } + process.waitUntilExit() + } + private func findPackage(in root: URL, pluginID: PluginID) throws -> URL { guard let enumerator = fileManager.enumerator( at: root, From 11296dfab6a50493485869df303f81778dd76674 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 30 Sep 2026 21:02:03 +0800 Subject: [PATCH 3/5] docs: document LSP plugin build ownership --- ...09-30-lsp-plugin-build-and-distribution.md | 100 ++++++++++++++++++ AGENTS.md | 6 ++ 2 files changed, 106 insertions(+) create mode 100644 .agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md 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 new file mode 100644 index 000000000..7d1da2395 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md @@ -0,0 +1,100 @@ +# Agent 笔记:LSP 插件构建与语言服务器资源归属 + +状态:已实现 + +## 先说结论 + +以后新增或修改带语言服务器(Language Server,负责通过 LSP 提供补全、诊断和跳转能力的进程)的插件,都必须沿用 PHP Support 的构建方式:插件声明固定版本和校验值,构建阶段下载、校验并把语言服务器放入插件包,插件包完成签名后再分发。主程序不再为插件维护第二套语言服务器路径,也不在运行时改写已安装的 app bundle。 + +插件拥有语言服务器的文件、启动入口和生命周期;Lithe 只负责插件包验证、插件启停、运行时发现和现有 LSP 会话编排。运行时资源写入用户级 Application Support、Caches 或临时目录,插件卸载、重装和回滚必须能够连同自己的语言服务器一起清理或替换。 + +## 问题 + +语言服务器通常包含可执行入口、第三方归档、许可证和平台相关资源。把这些文件直接放进主程序,或者让主程序在运行时自行下载,会带来几个问题:不使用该语言的用户承担下载和索引成本;插件签名边界不完整;语言服务器可能写入安装目录,破坏 app bundle 的发布基线;插件卸载后还可能留下脱离插件的运行时。 + +PHP Support 已经验证了独立插件包、固定 Intelephense 版本和插件拥有的版本目录可以覆盖完整链路,因此后续 LSP 插件沿用同一所有权边界。 + +## 决策 + +### 1. 插件清单是构建输入 + +每个带 LSP 的插件在自己的源码目录提供 `language-server.json`。清单至少固定以下事实:服务器版本、HTTPS 下载地址、SHA-256、归档格式、归档根目录、启动脚本相对路径和许可证路径。清单只描述构建所需的上游输入,不保存机器路径、用户目录或运行时缓存路径。 + +当前 PHP 插件的示例是 `Plugins/mac/Official/PhpSupport/language-server.json`。如果另一个语言服务器使用 zip、单文件可执行程序或不同的启动方式,应扩展构建脚本支持的格式,并保持“固定来源、固定校验、构建后签名”的原则;不能跳过校验直接把网络下载物复制进插件。 + +### 2. 构建阶段完成下载、解压和组装 + +官方 macOS 插件统一通过 `scripts/build-official-plugins.sh` 构建。该脚本负责: + +1. 从插件目录读取 `plugin.json` 和 `language-server.json`。 +2. 编译插件自己的 Swift 模块。 +3. 调用插件专属准备脚本下载并校验语言服务器归档。 +4. 将启动器、上游运行文件、许可证和语言服务器清单放入 bundle 的资源目录。 +5. 对完整 bundle 签名;签名完成后不得再修改其中内容。 + +PHP 的具体下载和组装逻辑位于 `scripts/prepare-php-language-server.sh`。后续插件可以复用相同的构建阶段和校验结构,但不能把某个语言的路径、版本或下载地址硬编码到宿主应用中。 + +### 3. 插件包是发布和安装单位 + +主程序默认分发清单不包含可选 LSP 插件。需要独立安装的插件使用与宿主兼容的签名构建,并作为单独的 release asset 发布。插件管理下载或导入包后,必须按以下顺序处理: + +1. 校验插件 manifest、宿主兼容版本和代码签名。 +2. 校验语言服务器清单和插件内的启动器。 +3. 把整个插件版本放到用户级插件版本目录。 +4. 在重启边界完成启用、替换或卸载,再让 LSP 控制中心显示可用状态。 + +macOS 当前的用户级目录是 `/Lithe/Plugins//versions/`。语言服务器必须位于该插件版本目录内,由插件版本目录拥有;不能另建一个由主程序长期管理的 PHP、JavaScript 或通用 `language-tools` 副本。 + +### 4. 运行时只发现已安装插件提供的入口 + +LSP 控制中心和语言工具发现沿用现有 Rust Core LSP 会话。插件启用后,组合根把该插件版本目录中的启动器根路径传给平台运行时;插件未安装、被禁用、隔离或等待重启时,不得重新启用宿主内置的旧路径或通用安装器。 + +语言符号、诊断、补全和类型分析继续由上游语言服务器负责。Lithe 只拥有会话生命周期、取消、超时、旧结果保护、资源预算和稳定的跨平台适配契约,不在 Core、Swift 和 Windows 前端各自实现第二套语言语义。 + +### 5. 运行时资源必须离开安装目录 + +构建脚本可以写入待签名的 bundle;已安装 app bundle 和 Windows 安装目录在运行时视为只读。下载临时文件、解压目录、插件状态、日志、锁和语言服务器工作区状态必须使用平台存储适配器放到 Application Support、Caches、临时目录或用户工作区。 + +正确做法:构建阶段把校验后的语言服务器放进待签名插件包,安装后复制整个版本包到用户级插件目录,运行时只读取启动器。 + +错误做法:启动时从 `Bundle.main.resourceURL` 解压语言服务器、在插件 bundle 内创建索引,或卸载插件时只删 `plugin.json` 而保留语言服务器目录。 + +### 6. 跨平台实现保持相同边界 + +macOS 使用原生 bundle 和 Developer ID 签名;Windows 使用自己的 Tauri 插件包和平台签名、缓存适配器。Windows 不得导入 Swift 插件实现,也不得因为跨平台而把 macOS 的目录或构建脚本复制到 Windows。两端都必须满足:插件自有资源、构建时校验、运行时只读安装目录、禁用和卸载可清理自有资源。 + +## 考虑过的备选方案 + +1. 把所有语言服务器随主程序打包:启动简单,但增加主程序体积和每个用户的维护成本,也无法实现按需安装。 +2. 只在主程序中记录可执行文件名,交给用户自行安装:减少构建工作,但插件无法保证版本、校验值和安装后的生命周期,重装与卸载也无法清理自己的资源。 +3. 运行时由主程序直接下载到共享语言工具目录:可以快速接入,却会形成主程序与插件的双重所有权,容易留下跨插件污染和不可验证的缓存。 +4. 为每种语言重新实现 LSP 进程和语义分析:已有 Rust Core 会话和上游语言服务器已经提供这些能力,重复实现会产生协议、项目状态和资源清理的第二个真源。 + +## 后果 + +用户只为安装的语言承担下载和索引成本,插件包可以独立发布、验证、重装和卸载。构建过程需要访问固定的上游归档,发布环境必须准备对应架构和签名身份;Node.js 等外部运行时仍由平台能力检查,不由插件偷偷写入安装目录。 + +插件包、解压结果、语言服务器下载缓存和运行时语言工具没有可靠的跨工作树身份标记,继续在 `scripts/worktree-resources.json` 中作为隔离资源处理,不能通过工作树复用脚本复制。 + +## 验证 + +- `./scripts/verify-official-plugins.sh`:检查官方插件构建、语言服务器清单、启动器和代码签名。 +- `./scripts/verify-runtime-bundle-immutability.sh`:确认典型启动和插件工作流不会改写已安装 bundle。 +- `node scripts/test-reuse-worktree-resources.mjs`:确认语言服务器下载和插件包不能跨工作树复用。 +- `./scripts/verify-agent-notes.sh`:确认本笔记格式、路径和验证命令有效。 +- 相关 macOS 测试:`MacPluginPackageDownloaderTests`、`MacRuntimeToolDiscoveryTests` 和 `PluginPackageStoreTests`。 +- 新增插件还必须验证下载失败、校验失败、取消、等待重启、重装、回滚、禁用和卸载后的进程与文件清理。 + +## 适用范围 + +- `Plugins/mac/Official/*/language-server.json` +- `Plugins/mac/Official/PhpSupport/` +- `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/` +- `windows/tauri/src/extensions/` +- `windows/tauri/src-tauri/src/language_tools.rs` +- `scripts/worktree-resources.json` +- `docs/ci-builds.md` diff --git a/AGENTS.md b/AGENTS.md index 997d80270..e9fc0f076 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,12 @@ 说明。生成资源没有可靠 identity stamp 时不得注册为可复用资源;可变构建状态 和 LSP workspace 状态不得跨 worktree 共享。 +### LSP 插件构建入口 + +新增或修改语言服务器插件前,先阅读 +[LSP 插件构建与语言服务器资源归属](.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md)。 +所有 LSP 插件都必须在构建阶段固定来源并校验语言服务器,把完整资源放入插件包后签名;运行时只能从已安装插件发现入口,不能把下载物、解压物或插件状态写入 app bundle、安装目录或跨工作树共享缓存。 + ## 跨平台功能同步 跨平台功能矩阵的字段、状态语义、更新流程和 CI 例外规则以 From 5facb3d1d970651bedc1d810ab97c4a000ff12c6 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 1 Oct 2026 01:56:33 +0800 Subject: [PATCH 4/5] feat(macos): publish independently signed PHP plugin packages --- ...-php-support-plugin-ownership-and-plans.md | 6 +- ...cos-update-signing-and-release-strategy.md | 27 +- .github/workflows/release-macos.yml | 38 ++- .github/workflows/release-preview-macos.yml | 36 ++- Package.swift | 18 +- Plugins/mac/Official/PhpSupport/plugin.json | 2 +- docs/ci-builds.md | 2 +- docs/development/platform-parity-matrix.csv | 1 + docs/development/platform-parity-matrix.md | 17 +- .../MacOS/Plugins/MacPluginPackageStore.swift | 41 +++ .../Catalog/BuiltInModuleCatalog.swift | 7 +- .../LitheModuleAPI/Plugins/PluginTypes.swift | 1 + .../PluginPackageSignature.swift | 242 ++++++++++++++++++ .../PluginPackageSignatureTests.swift | 101 ++++++++ .../Tools/LithePluginPackageSigner/main.swift | 80 ++++++ scripts/build-official-plugins.sh | 44 +++- scripts/verify-shared-contracts.sh | 2 +- shared/platform-feature-matrix.json | 31 ++- 18 files changed, 633 insertions(+), 63 deletions(-) create mode 100644 macos/Sources/LithePluginPackageSigning/PluginPackageSignature.swift create mode 100644 macos/Tests/LitheTests/PluginPackageSignatureTests.swift create mode 100644 macos/Tools/LithePluginPackageSigner/main.swift 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 995e4e5c3..32a9d13b0 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 @@ -25,7 +25,7 @@ PHP 支持由用户选择安装和启用,主程序不携带 PHP 插件包、No ### 能力与生命周期 - PHP 的 LSP 使用现有 Rust Core 会话,以 `intelephense --stdio` 启动。符号、类型和诊断仍由上游服务拥有;主机不实现第二套 PHP 语义分析。 -- macOS 插件管理页是 PHP 包和 Intelephense 的生命周期唯一入口:构建阶段按 `language-server.json` 下载并校验 npm tarball,把 launcher 和运行包放入插件 bundle;下载器再下载完整插件 zip,`MacPluginPackageStore` 同时验证插件 manifest、签名和语言服务器 launcher。安装、重装、回滚和卸载都针对同一个插件版本目录执行,因此不会留下脱离插件的 LSP。LSP 控制中心只显示当前项目的 PHP 语言服务器开关和运行状态,发现未安装、未启用或待重启时引导回插件管理页,不提供包操作按钮。 +- macOS 插件管理页是 PHP 包和 Intelephense 的生命周期唯一入口:构建阶段按 `language-server.json` 下载并校验 npm tarball,把 launcher 和运行包放入插件 bundle;下载器再下载完整插件 zip,`MacPluginPackageStore` 先验证原生 bundle,再用内置 publisher Ed25519 公钥验证完整包清单、插件 ID、版本和文件 SHA-256,最后验证语言服务器 launcher。安装、重装、回滚和卸载都针对同一个插件版本目录执行,因此不会留下脱离插件的 LSP。LSP 控制中心只显示当前项目的 PHP 语言服务器开关和运行状态,发现未安装、未启用或待重启时引导回插件管理页,不提供包操作按钮。 - macOS 运行和测试使用插件模块持有的执行 session。相对文件名不做 trim,以 `-` 开头时加 `./`,避免把文件名当作命令选项。 - Windows 只有已安装且启用 PHP 扩展时才读取 Composer/PHPUnit 清单、展示运行入口;执行前再次检查开关。Composer 的字符串和字符串数组均交给 `composer run -- ` 执行,不在主机模拟脚本语义。 - Windows PHP 运行复用 Run 的输出面板和 native 进程启动能力,通用宿主服务在首个 await 之前按插件 ID 和工作区登记会话。禁用或关闭工作区时等待在途启动,再停止其拥有的 execution ID;自然结束释放所有权。不得通过普通终端事件绕过这个流程。 @@ -57,7 +57,7 @@ PHP 支持由用户选择安装和启用,主程序不携带 PHP 插件包、No ## 后果 -不使用 PHP 的用户不承担语言服务器下载、索引和进程成本。代价是首次使用需要显式安装插件和 Node.js;macOS 在线包必须使用与宿主一致的签名,未配置 Developer ID 的调试或预览构建仍只能使用本地导入进行测试。Windows 目前提供 Composer 脚本及整套 PHPUnit,未声明支持 macOS 已有的单方法测试发现。Windows 本地包暂不提供在线分发、自动更新或签名身份验证,替换版本需先卸载再导入;包格式仅用于小型 Worker 语言插件。目标平台运行验证未完成前,功能矩阵保持 pending。 +不使用 PHP 的用户不承担语言服务器下载、索引和进程成本。代价是首次使用需要显式安装插件和 Node.js;macOS 在线包需要 Lithe publisher Ed25519 签名,但不要求发布者持有 Developer ID;未配置 publisher secret 的 debug 包仍只能用于本地模块验证,不能通过正式安装器。Windows 目前提供 Composer 脚本及整套 PHPUnit,未声明支持 macOS 已有的单方法测试发现。Windows 本地包暂不提供在线分发、自动更新或签名身份验证,替换版本需先卸载再导入;包格式仅用于小型 Worker 语言插件。目标平台运行验证未完成前,功能矩阵保持 pending。 插件构建产物、PHPUnit 的 vendor 和应用语言工具缓存没有可靠的跨工作树身份标记,均在 `scripts/worktree-resources.json` 的 excludedResources 中排除。不得把它们共享为可变缓存。 @@ -73,7 +73,7 @@ PHP 支持由用户选择安装和启用,主程序不携带 PHP 插件包、No - `MacRuntimeToolDiscoveryTests`:验证启用的 PHP 插件版本目录优先提供 Intelephense launcher。 - `PluginPackageStoreTests/reinstallCanReplaceTheActiveVersionOnlyAfterValidation`:验证重装不会绕过签名校验,并在校验完成后替换当前版本。 - `prepare-php-language-server.sh`:按 JSON 清单下载、校验并组装 Intelephense 运行包;插件版本目录删除时一并删除 launcher 和缓存文件。 -- `.github/workflows/release-macos.yml`:Developer ID 构建额外发布架构对应的 PHP 插件 zip;未配置 Developer ID 时不发布可在线安装的独立包。 +- `.github/workflows/release-macos.yml` 和 `.github/workflows/release-preview-macos.yml`:每个架构都发布 PHP 插件 zip;`LITHE_PLUGIN_PACKAGE_PRIVATE_KEY` 缺失或不匹配时工作流失败,避免发布无法被客户端识别的包。 - `./scripts/test-macos.sh --filter LithePhpSupportModuleTests`:模块、路径与禁用清理测试。 - `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 交叉编译不等于运行验收。 diff --git a/.agents/notes/implemented/process/2026-09-13-macos-update-signing-and-release-strategy.md b/.agents/notes/implemented/process/2026-09-13-macos-update-signing-and-release-strategy.md index 5a22774f9..37ebefdfa 100644 --- a/.agents/notes/implemented/process/2026-09-13-macos-update-signing-and-release-strategy.md +++ b/.agents/notes/implemented/process/2026-09-13-macos-update-signing-and-release-strategy.md @@ -4,7 +4,7 @@ ## 先说结论 -更新包的真实性由 Sparkle 的 EdDSA 签名保证,Developer ID 签名是可选的分发能力,不是更新信任的唯一依据。stable 和 preview 完全隔离,回滚只接受内置公钥信任的更新,避免测试版本或伪造清单影响正式用户。 +更新包和独立官方插件包的真实性分别由 Sparkle EdDSA 与 Lithe Ed25519 签名保证,Developer ID 签名是可选的分发能力,不是这两类内容信任的唯一依据。stable 和 preview 完全隔离,回滚和插件安装只接受内置公钥信任的内容,避免测试版本或伪造清单影响正式用户。 ## 问题 @@ -31,6 +31,20 @@ runner keychain,用后即删。Developer ID 签名不等于公证,也不保 Gatekeeper 首次运行警告消失——这些是独立的发布关注点,更新器不自动 清除 quarantine。 +### 独立官方插件包 + +PHP Support 作为独立 GitHub Release asset 发布,不再把“能否使用 Developer ID +证书”当成是否生成插件包的条件。构建阶段先完成原生 bundle 的代码签名,再由 +`LithePluginPackageSigner` 使用 `LITHE_PLUGIN_PACKAGE_PRIVATE_KEY` 为包内每个文件 +生成 SHA-256 清单和 Ed25519 签名;签名文档本身不进入被签名文件列表,避免验证时 +出现自引用。Lithe 内置 `lithe-official-plugins-v1` 公钥,安装时先验证原生 bundle, +再验证完整包清单、插件 ID 和版本,任一文件被替换都会拒绝安装。 + +stable 和 preview 工作流都必须配置同一 repository secret +`LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`;私钥只在构建 runner 的标准输入中使用,不能写入 +仓库、release asset 或日志。公钥轮换需要先发布能识别新 key ID 的客户端,再更新 +secret 并重新生成包,不能只替换 GitHub secret。 + ### 全量与差分双轨发布 stable 工作流继续发布 DMG + SHA-256 + `latest-macos.json` 供旧客户端 @@ -102,6 +116,10 @@ bundle identifier、展示版本、可执行文件架构、渠道和最低系统 少点一次 Gatekeeper 提示。但这本质上是绕过 macOS 的下载来源追踪 机制,属于安全职责之外的东西,因此明确不做,交由既有的可信来源 恢复步骤处理。 +- **要求插件使用 Developer ID 或与宿主同 Team ID 签名**:能复用 Apple + 的代码签名身份,但会让没有证书的发布环境无法生成独立包,也无法表达“包由 + Lithe 发布者签发”的信任关系,因此改用包级 Ed25519 清单,同时保留 bundle + 的原生代码签名校验。 ## 后果 @@ -116,6 +134,9 @@ bundle identifier、展示版本、可执行文件架构、渠道和最低系统 - 回滚路径的安全性依赖客户端内置公钥永不改变;如果需要轮换 `SPARKLE_PRIVATE_KEY`,替换 repository secret 本身不够,必须单独 规划迁移(重签或双签过渡期),否则旧客户端会拒绝新签名的更新。 +- 独立插件包的安装安全性同样依赖客户端内置的 publisher 公钥;没有匹配 secret + 的构建会在 release workflow 中失败,而 debug 构建可以生成供模块验证器使用的 + 未发布包,但不能绕过正式安装器的包签名要求。 - 需要重新评估的触发条件:如果差分更新的资产数量随架构或渠道增多 逼近 900 的清理阈值,或者需要支持两个以上的更新渠道,当前基于 "30 个 build + 3 个 zip 基线"的保留规则需要重新设计。 @@ -127,6 +148,7 @@ bundle identifier、展示版本、可执行文件架构、渠道和最低系统 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh --max-seconds 60 -- --filter StableRollbackDiskImageIntegrationTests ./scripts/test-macos.sh ./scripts/verify-macos-package.sh +./scripts/verify-official-plugins.sh sparkle_tools=$(zsh scripts/prepare-sparkle-tools.sh) ruby scripts/test-sparkle-update.rb "$sparkle_tools" actionlint .github/workflows/release-macos.yml .github/workflows/release-preview-macos.yml .github/workflows/ci-macos.yml @@ -150,4 +172,7 @@ Sparkle 版本间升级、缺失/损坏的 delta、下载中断、权限不足 - `scripts/create-macos-update-manifest.rb` - `.github/workflows/release-macos.yml` - `.github/workflows/release-preview-macos.yml` +- `scripts/build-official-plugins.sh` +- `macos/Sources/LithePluginPackageSigning/` +- `macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift` - `docs/architecture/macos-updates.md` diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 9d94a6558..86ba94c35 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -115,31 +115,29 @@ jobs: echo "Incomplete Developer ID configuration: provide the certificate or remove the optional identity and password." exit 1 else - echo "Developer ID is not configured; using ad-hoc app signing and Sparkle EdDSA update signatures." + echo "Developer ID is not configured; using ad-hoc app signing. Official plugin packages use Lithe Ed25519 signatures." fi - name: Build and package the App env: LITHE_SPARKLE_PUBLIC_KEY: ${{ vars.SPARKLE_PUBLIC_KEY }} + LITHE_PLUGIN_PACKAGE_PRIVATE_KEY: ${{ secrets.LITHE_PLUGIN_PACKAGE_PRIVATE_KEY }} LITHE_ARCH: ${{ matrix.architecture }} LITHE_VERSION: ${{ needs.prepare.outputs.version }} LITHE_BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} run: | test -n "$LITHE_SPARKLE_PUBLIC_KEY" || { echo "Configure SPARKLE_PUBLIC_KEY before releasing"; exit 1; } + test -n "$LITHE_PLUGIN_PACKAGE_PRIVATE_KEY" || { echo "Configure LITHE_PLUGIN_PACKAGE_PRIVATE_KEY before releasing"; exit 1; } ./scripts/package-app.sh - if [[ -n "${LITHE_CODESIGN_IDENTITY:-}" ]]; then - plugin_root=$(LITHE_CODESIGN_IDENTITY="$LITHE_CODESIGN_IDENTITY" \ - scripts/build-official-plugins.sh \ - --configuration release \ - --triple "${LITHE_ARCH}-apple-macosx" \ - --plugin-id dev.lithe.plugin.php-support \ - --output "dist/official-plugins/${LITHE_ARCH}") - ditto -c -k --sequesterRsrc --keepParent \ - "$plugin_root/dev.lithe.plugin.php-support" \ - "dist/Lithe-PHP-Support-${LITHE_VERSION}-${LITHE_ARCH}.zip" - else - echo "Developer ID is not configured; skipping externally installable PHP plugin archive." - fi + plugin_root=$(LITHE_CODESIGN_IDENTITY="${LITHE_CODESIGN_IDENTITY:--}" \ + scripts/build-official-plugins.sh \ + --configuration release \ + --triple "${LITHE_ARCH}-apple-macosx" \ + --plugin-id dev.lithe.plugin.php-support \ + --output "dist/official-plugins/${LITHE_ARCH}") + ditto -c -k --sequesterRsrc --keepParent \ + "$plugin_root/dev.lithe.plugin.php-support" \ + "dist/Lithe-PHP-Support-${LITHE_VERSION}-${LITHE_ARCH}.zip" ./scripts/create-dmg.sh dmg_name="Lithe-${LITHE_VERSION}-${LITHE_ARCH}.dmg" (cd dist && shasum -a 256 "$dmg_name" > "$dmg_name.sha256") @@ -168,9 +166,7 @@ jobs: "$app_path/Contents/Info.plist" done plugin_archive="dist/Lithe-PHP-Support-${LITHE_VERSION}-${LITHE_ARCH}.zip" - if [[ -e "$plugin_archive" ]]; then - test -s "$plugin_archive" - fi + test -s "$plugin_archive" lipo "$app_path/Contents/MacOS/Lithe" -verify_arch "$LITHE_ARCH" hdiutil imageinfo "$dmg_path" > /dev/null test -s "$dmg_path" @@ -197,8 +193,7 @@ jobs: if-no-files-found: error retention-days: 7 - - name: Upload optional PHP plugin archive - if: ${{ hashFiles(format('dist/Lithe-PHP-Support-{0}-{1}.zip', needs.prepare.outputs.version, matrix.architecture)) != '' }} + - name: Upload PHP plugin archive uses: actions/upload-artifact@v7 with: name: macos-php-plugin-${{ matrix.architecture }} @@ -288,9 +283,8 @@ jobs: for architecture in arm64 x86_64; do upload_args+=(dist/sparkle-"$architecture"/*) plugin_archive="dist/Lithe-PHP-Support-${LITHE_VERSION}-${architecture}.zip" - if [[ -e "$plugin_archive" ]]; then - upload_args+=("$plugin_archive") - fi + test -s "$plugin_archive" + upload_args+=("$plugin_archive") done create_args+=(--notes-file "$notes_file") diff --git a/.github/workflows/release-preview-macos.yml b/.github/workflows/release-preview-macos.yml index 3598a111b..ad4cd7117 100644 --- a/.github/workflows/release-preview-macos.yml +++ b/.github/workflows/release-preview-macos.yml @@ -106,7 +106,7 @@ jobs: echo "Incomplete Developer ID configuration: provide the certificate or remove the optional identity and password." exit 1 else - echo "Developer ID is not configured; the preview app will be ad-hoc signed and no externally installable PHP plugin archive will be published." + echo "Developer ID is not configured; the preview app will be ad-hoc signed. Official plugin packages use Lithe Ed25519 signatures." fi - name: Build and package the preview app @@ -114,6 +114,7 @@ jobs: LITHE_ARCH: ${{ matrix.architecture }} LITHE_VERSION: ${{ env.PREVIEW_VERSION }} LITHE_BUILD_NUMBER: ${{ needs.prepare.outputs.build }} + LITHE_PLUGIN_PACKAGE_PRIVATE_KEY: ${{ secrets.LITHE_PLUGIN_PACKAGE_PRIVATE_KEY }} LITHE_BUILD_TIMESTAMP: ${{ needs.prepare.outputs.timestamp }} LITHE_BUILD_GIT_BRANCH: ${{ github.event_name == 'workflow_dispatch' && inputs.source_branch || env.PREVIEW_BRANCH }} LITHE_UPDATE_CHANNEL: preview @@ -121,20 +122,17 @@ jobs: LITHE_SPARKLE_PUBLIC_KEY: ${{ vars.SPARKLE_PUBLIC_KEY }} run: | test -n "$LITHE_SPARKLE_PUBLIC_KEY" || { echo "Configure SPARKLE_PUBLIC_KEY before publishing preview updates"; exit 1; } + test -n "$LITHE_PLUGIN_PACKAGE_PRIVATE_KEY" || { echo "Configure LITHE_PLUGIN_PACKAGE_PRIVATE_KEY before publishing preview updates"; exit 1; } ./scripts/package-app.sh - if [[ -n "${LITHE_CODESIGN_IDENTITY:-}" ]]; then - plugin_root=$(LITHE_CODESIGN_IDENTITY="$LITHE_CODESIGN_IDENTITY" \ - scripts/build-official-plugins.sh \ - --configuration release \ - --triple "${LITHE_ARCH}-apple-macosx" \ - --plugin-id dev.lithe.plugin.php-support \ - --output "dist/official-plugins/${LITHE_ARCH}") - ditto -c -k --sequesterRsrc --keepParent \ - "$plugin_root/dev.lithe.plugin.php-support" \ - "dist/Lithe-PHP-Support-${LITHE_VERSION}-${LITHE_ARCH}.zip" - else - echo "Developer ID is not configured; skipping externally installable PHP plugin archive." - fi + plugin_root=$(LITHE_CODESIGN_IDENTITY="${LITHE_CODESIGN_IDENTITY:--}" \ + scripts/build-official-plugins.sh \ + --configuration release \ + --triple "${LITHE_ARCH}-apple-macosx" \ + --plugin-id dev.lithe.plugin.php-support \ + --output "dist/official-plugins/${LITHE_ARCH}") + ditto -c -k --sequesterRsrc --keepParent \ + "$plugin_root/dev.lithe.plugin.php-support" \ + "dist/Lithe-PHP-Support-${LITHE_VERSION}-${LITHE_ARCH}.zip" ./scripts/create-dmg.sh dmg_name="Lithe-${LITHE_VERSION}-${LITHE_ARCH}.dmg" (cd dist && shasum -a 256 "$dmg_name" > "$dmg_name.sha256") @@ -179,8 +177,7 @@ jobs: compression-level: 0 retention-days: 14 - - name: Upload optional PHP plugin archive - if: ${{ hashFiles(format('dist/Lithe-PHP-Support-{0}-{1}.zip', env.PREVIEW_VERSION, matrix.architecture)) != '' }} + - name: Upload PHP plugin archive uses: actions/upload-artifact@v7 with: name: macos-php-plugin-${{ matrix.architecture }} @@ -282,10 +279,9 @@ jobs: for architecture in arm64 x86_64; do plugin_archive="dist/Lithe-PHP-Support-${LITHE_VERSION}-${architecture}.zip" - if [[ -e "$plugin_archive" ]]; then - gh release upload "$RELEASE_TAG" "$plugin_archive" \ - --repo "$GITHUB_REPOSITORY" --clobber - fi + test -s "$plugin_archive" + gh release upload "$RELEASE_TAG" "$plugin_archive" \ + --repo "$GITHUB_REPOSITORY" --clobber done # Publish every archive before replacing either feed. Retention protects diff --git a/Package.swift b/Package.swift index 234edc6c7..5f5fcd9cb 100644 --- a/Package.swift +++ b/Package.swift @@ -25,10 +25,12 @@ let package = Package( .library(name: "LitheWorkspaceModule", targets: ["LitheWorkspaceModule"]), .library(name: "LitheGoSupportModule", targets: ["LitheGoSupportModule"]), .library(name: "LithePhpSupportModule", targets: ["LithePhpSupportModule"]), + .library(name: "LithePluginPackageSigning", targets: ["LithePluginPackageSigning"]), .executable(name: "LitheCoreVerifier", targets: ["LitheCoreVerifier"]), .executable(name: "LitheGitGraphVerifier", targets: ["LitheGitGraphVerifier"]), .executable(name: "LitheGitPerformanceVerifier", targets: ["LitheGitPerformanceVerifier"]), - .executable(name: "LitheOfficialPluginVerifier", targets: ["LitheOfficialPluginVerifier"]) + .executable(name: "LitheOfficialPluginVerifier", targets: ["LitheOfficialPluginVerifier"]), + .executable(name: "LithePluginPackageSigner", targets: ["LithePluginPackageSigner"]) ], dependencies: [ .package(url: "https://github.com/sparkle-project/Sparkle.git", exact: "2.10.0"), @@ -89,6 +91,11 @@ let package = Package( .target(name: "LitheWorkspaceModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "macos/Sources/LitheWorkspaceModule", swiftSettings: [.swiftLanguageMode(.v6)]), .target(name: "LitheGoSupportModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "Plugins/mac/Official/GoSupport/Sources/LitheGoSupportModule", swiftSettings: [.swiftLanguageMode(.v6)]), .target(name: "LithePhpSupportModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule", swiftSettings: [.swiftLanguageMode(.v6)]), + .target( + name: "LithePluginPackageSigning", + path: "macos/Sources/LithePluginPackageSigning", + swiftSettings: [.swiftLanguageMode(.v6)] + ), .target( name: "LitheRustCore", path: "macos/Sources/LitheRustCore", @@ -111,6 +118,7 @@ let package = Package( "LitheDebugModule", "LitheLanguageIntelligenceModule", "LitheWorkspaceModule", + "LithePluginPackageSigning", "LitheRustCore", .product(name: "SwiftTerm", package: "SwiftTerm"), .product(name: "Sparkle", package: "Sparkle") @@ -131,7 +139,7 @@ let package = Package( ), .testTarget( name: "LitheTests", - dependencies: ["Lithe", "LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts", "LitheGitModule", "LitheDatabaseModule", "LitheAIAssistanceModule", "LitheAgentConversationModule", "LitheLanguageIntelligenceModule", "LitheGoSupportModule", "LithePhpSupportModule", .product(name: "Testing", package: "swift-testing")], + dependencies: ["Lithe", "LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts", "LitheGitModule", "LitheDatabaseModule", "LitheAIAssistanceModule", "LitheAgentConversationModule", "LitheLanguageIntelligenceModule", "LitheGoSupportModule", "LithePhpSupportModule", "LithePluginPackageSigning", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheTests", resources: [ .copy("Fixtures") @@ -271,6 +279,12 @@ let package = Package( dependencies: ["LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts"], path: "macos/Tests/LitheOfficialPluginVerifier", swiftSettings: [.swiftLanguageMode(.v6)] + ), + .executableTarget( + name: "LithePluginPackageSigner", + dependencies: ["LithePluginPackageSigning"], + path: "macos/Tools/LithePluginPackageSigner", + swiftSettings: [.swiftLanguageMode(.v6)] ) ] ) diff --git a/Plugins/mac/Official/PhpSupport/plugin.json b/Plugins/mac/Official/PhpSupport/plugin.json index 3aad8713a..d8527d201 100644 --- a/Plugins/mac/Official/PhpSupport/plugin.json +++ b/Plugins/mac/Official/PhpSupport/plugin.json @@ -11,7 +11,7 @@ "vendor": { "id": "dev.lithe", "displayName": "Lithe", - "signatureRequirement": "sameTeamAsHost" + "signatureRequirement": "publisherPackage" }, "entrypoint": { "kind": "nativeBundle", diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 3ad394f31..e105ae36b 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -195,7 +195,7 @@ SHA-256;Cargo、SwiftPM 和 Bun 使用各自的 lockfile、版本与完整性 identity stamp,任何复制阶段都禁止共享。资源清单 `jdt-maven-settings` 显式排除, 复用脚本直接拒绝该资源,不进入下载或生成物校验路由。 -PHP 插件包在 `.build///OfficialPlugins` 中独立构建,绑定宿主 API、Swift 工具链、架构和签名,通过 `LitheOfficialPluginVerifier` 验证;无可靠 identity stamp,不跨工作树复制。插件安装后的 Intelephense 位于 +PHP 插件包在 `.build///OfficialPlugins` 中独立构建,绑定宿主 API、Swift 工具链、架构和签名,通过 `LitheOfficialPluginVerifier` 验证;发布配置还要求 repository secret `LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`,由 `LithePluginPackageSigner` 对完整包生成 `lithe-plugin-signature.json`,客户端用内置公钥验证后才允许安装。无可靠 identity stamp,不跨工作树复制。插件安装后的 Intelephense 位于 `/Lithe/Plugins//versions//PhpSupport.bundle/Contents/Resources/LanguageServers/php`,由插件版本目录拥有,重装、回滚和卸载随插件一起处理,不是工作树构建缓存。PHPUnit 测试夹具的 `shared/fixtures/phpunit-project/vendor` 也由当前工作树独立安装。以上项目在资源清单 `excludedResources` 中明确排除,复用脚本会拒绝显式复制请求。 如果后续新增可复用资源,必须同步更新注册表、校验器、脚本测试和本节说明。 diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index d639d80ab..a8ca0e829 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -107,6 +107,7 @@ editor-file-encoding-reopen,编辑器,文本编辑,按指定编码重新打开 editor-file-encoding-save,编辑器,文本编辑,按指定编码保存文本文件,已实现,待验证,已实现,待验证,Editor,在 macOS 和 Windows 实机验证 UTF-8、UTF-8 BOM 与 GBK/GB18030 的自动识别,并分别验证 Shift JIS、Windows-1252 的指定编码重新打开、编码转换保存、脏文件选择和外部修改保护。,,macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift; macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift; macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift; macos/Sources/Lithe/Platform/MacOS/FileSystem/MacDocumentEncoding.swift,windows/tauri/src/features/command-palette/components/encoding-picker.tsx; windows/tauri/src/features/editor/stores/editor-app.store.ts; windows/tauri/crates/project/src/document_file.rs php-optional-plugin,语言支持,PHP,PHP 按需安装与插件生命周期,已实现,待验证,已实现,待验证,PHP Support,macOS 在干净安装上打开插件管理,确认可从当前发行渠道下载并安装 PHP、失败时可从磁盘导入;安装后重启,在 LSP 界面只控制当前项目开关和状态,验证重装、卸载均在重启后生效且用户工具保留。Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。,,Plugins/mac/Official/PhpSupport; scripts/official-plugin-distribution.mjs; 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,Plugins/win/Official/PhpSupport; windows/tauri/src/extensions/registry/extension-store-lifecycle.ts; windows/tauri/src-tauri/src/language_tools.rs; windows/tauri/src/extensions/packages/local-extension-package.ts; scripts/verify-windows-plugin-isolation.mjs php-run-test,语言支持,PHP,PHP 插件运行与 PHPUnit 测试,已实现,待验证,部分实现,待验证,PHP Support,macOS 运行 PHP 文件和单条/整套 PHPUnit;Windows 运行字符串/数组 Composer script 和整套 PHPUnit。禁用后菜单消失、运行进程退出,特殊文件名保持原样。Windows 暂不支持单方法发现。,,Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Capabilities/PhpExecutionCapability.swift; macos/Tests/LitheTests/RealPhpIntegrationTests.swift,Plugins/win/Official/PhpSupport/plugin.ts; windows/tauri/src/extensions/run; windows/tauri/src/extensions/ui/services/ui-extension-worker-runtime.test.ts +macos-php-plugin-publisher-signature,发布与安装,官方插件,独立 PHP 插件包使用 Lithe publisher 签名并可发布到 GitHub Release,已实现,待验证,平台专属,不适用,Release,macOS 构建配置 LITHE_PLUGIN_PACKAGE_PRIVATE_KEY,在 arm64 和 x86_64 生成 PHP zip;解压后验证签名文档覆盖完整文件树,篡改任意文件、manifest、版本或签名后确认插件安装被拒绝,并从 GitHub Release 下载包完成一次安装。Windows 使用独立本地插件包流程,本能力不适用。,,macos/Sources/LithePluginPackageSigning/PluginPackageSignature.swift; macos/Tools/LithePluginPackageSigner/main.swift; macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift; scripts/build-official-plugins.sh; .github/workflows/release-macos.yml; .github/workflows/release-preview-macos.yml; Plugins/mac/Official/PhpSupport/plugin.json,Plugins/win/Official/PhpSupport/plugin.ts; windows/tauri/src/extensions/packages/local-extension-package.ts git-workspace-staged-commit,版本控制,Git,按文件所属仓库批量提交及推送,保留每仓库结果,已实现,待验证,已实现,待验证,Git,勾选两个独立仓库文件,一次提交并推送,验证各自 HEAD 和远程;停止一个操作后其余独立仓库继续,已成功仓库不会回滚。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift; macos/Sources/Lithe/Views/Git/CommitAreaView.swift; macos/Tests/LitheGitModuleTests/GitModuleTests.swift; rust/lithe-core/src/git/commit_state.rs; rust/lithe-core/src/tests/git_workspace_commit.rs; rust/lithe-core/src/git/workspace_commit.rs; rust/lithe-core/src/git/workspace_commit/tests.rs; shared/fixtures/git/workspace-commit-workflow-v1.json,windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/git/components/git-commit-panel.tsx; windows/tauri/src/features/git/components/git-workspace-commit-review.tsx; windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts; windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts; windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx; windows/tauri/src-tauri/src/platform.rs; shared/fixtures/git/workspace-commit-workflow-v1.json; windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx; windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx git-submodule-commit-plan,版本控制,Git,子模块先提交与推送、自动更新父引用、计划变化再次确认,已实现,待验证,已实现,待验证,Git,只勾选孙仓库文件,确认计划自动列出父祖仓库的引用更新且可关闭;确认期间改变暂存内容或分支,必须显示新计划再次确认。父仓库未暂存文件不能被带入;只勾选父引用时先推送子仓库现有提交,Git 发布检查应拒绝未发布的子引用。子仓库元数据被外部删除后必须拒绝读取和写入,不能向上回退父仓库。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift; macos/Sources/Lithe/Views/Git/CommitAreaView.swift; macos/Tests/LitheGitModuleTests/GitModuleTests.swift; rust/lithe-core/src/git/commit_state.rs; rust/lithe-core/src/tests/git_workspace_commit.rs; rust/lithe-core/src/git/workspace_commit.rs; rust/lithe-core/src/git/workspace_commit/tests.rs; shared/fixtures/git/workspace-commit-workflow-v1.json,windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/git/components/git-commit-panel.tsx; windows/tauri/src/features/git/components/git-workspace-commit-review.tsx; windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts; windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts; windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx; windows/tauri/src-tauri/src/platform.rs; shared/fixtures/git/workspace-commit-workflow-v1.json; windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx; windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx git-workspace-commit-retry,版本控制,Git,失败后只重试未完成的提交或推送步骤,已实现,待验证,已实现,待验证,Git,让子仓库推送失败,确认父仓库被阻塞、独立仓库成功;重试计划应显示子仓库只推送,验证子仓库提交数不增加,随后才提交和推送父仓库。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift; macos/Sources/Lithe/Views/Git/CommitAreaView.swift; macos/Tests/LitheGitModuleTests/GitModuleTests.swift; rust/lithe-core/src/git/commit_state.rs; rust/lithe-core/src/tests/git_workspace_commit.rs; rust/lithe-core/src/git/workspace_commit.rs; rust/lithe-core/src/git/workspace_commit/tests.rs; shared/fixtures/git/workspace-commit-workflow-v1.json,windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/git/components/git-commit-panel.tsx; windows/tauri/src/features/git/components/git-workspace-commit-review.tsx; windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts; windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts; windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx; windows/tauri/src-tauri/src/platform.rs; shared/fixtures/git/workspace-commit-workflow-v1.json; windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx; windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 6e05a4cde..ea78ad8e7 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -2,11 +2,11 @@ > 本页由 `shared/platform-feature-matrix.json` 自动生成。不要直接编辑本文件;新增或变更功能时更新源数据,再运行 `node scripts/generate-platform-feature-matrix.mjs`。 -- 最后复核:2026-09-29 +- 最后复核:2026-10-01 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:115 -- macOS:实现:✅ 101 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 4 平台专属;验证:✔️ 0 已验证,🔍 104 待验证,— 11 不适用 -- Windows:实现:✅ 98 已实现,🟡 11 部分实现,❌ 5 未实现,🧩 1 平台专属;验证:✔️ 0 已验证,🔍 109 待验证,— 6 不适用 +- 功能项:116 +- macOS:实现:✅ 102 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 4 平台专属;验证:✔️ 0 已验证,🔍 105 待验证,— 11 不适用 +- Windows:实现:✅ 98 已实现,🟡 11 部分实现,❌ 5 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 109 待验证,— 7 不适用 ## 实现状态定义 @@ -285,6 +285,15 @@ +
+发布与安装 · 1 个能力点 + +| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | +| --- | --- | --- | --- | --- | --- | --- | +| 官方插件 | **独立 PHP 插件包使用 Lithe publisher 签名并可发布到 GitHub Release**
macos-php-plugin-publisher-signature | ✅ 已实现
🔍 待验证
`macos/Sources/LithePluginPackageSigning/PluginPackageSignature.swift`、`macos/Tools/LithePluginPackageSigner/main.swift`、`macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift`、`scripts/build-official-plugins.sh`、`.github/workflows/release-macos.yml`、`.github/workflows/release-preview-macos.yml`、`Plugins/mac/Official/PhpSupport/plugin.json` | 🧩 平台专属
— 不适用
`Plugins/win/Official/PhpSupport/plugin.ts`、`windows/tauri/src/extensions/packages/local-extension-package.ts` | Release | macOS 构建配置 LITHE_PLUGIN_PACKAGE_PRIVATE_KEY,在 arm64 和 x86_64 生成 PHP zip;解压后验证签名文档覆盖完整文件树,篡改任意文件、manifest、版本或签名后确认插件安装被拒绝,并从 GitHub Release 下载包完成一次安装。Windows 使用独立本地插件包流程,本能力不适用。 | | + +
+
插件与扩展 · 1 个能力点 diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift index ca22fcf71..040e6bf9c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift @@ -1,6 +1,8 @@ +import CryptoKit import Foundation import LitheApplicationKernel import LitheModuleAPI +import LithePluginPackageSigning import Security protocol PluginPackageSignatureVerifying { @@ -20,6 +22,8 @@ enum PluginPackageStoreError: Error, Equatable, LocalizedError { case unsignedCode(URL) case invalidCodeSignature(URL) case signingTeamMismatch + case missingPackageSignature + case invalidPackageSignature(String) case retiredPlugin(PluginID) case invalidInstalledPlugin(PluginID?, String) @@ -37,6 +41,8 @@ enum PluginPackageStoreError: Error, Equatable, LocalizedError { case .unsignedCode(let url): "Plugin code is not signed: \(url.lastPathComponent)." case .invalidCodeSignature(let url): "Plugin signature is invalid: \(url.lastPathComponent)." case .signingTeamMismatch: "Plugin and host application signing teams do not match." + case .missingPackageSignature: "The official plugin package signature is missing." + case .invalidPackageSignature(let detail): "The official plugin package signature is invalid: \(detail)" case .retiredPlugin(let id): "Plugin \(id) has been removed from Lithe. Uninstall the old package." case .invalidInstalledPlugin(let id, let message): if let id { @@ -532,6 +538,10 @@ struct MacOfficialPluginSignatureVerifier: PluginPackageSignatureVerifying { guard SecStaticCodeCheckValidity(pluginCode, validationFlags, nil) == errSecSuccess else { throw PluginPackageStoreError.invalidCodeSignature(pluginBundleURL) } + if manifest.vendor.signatureRequirement == .publisherPackage { + try verifyPublisherPackageSignature(packageAt: packageURL, manifest: manifest) + return + } let pluginTeam = try teamIdentifier(for: pluginCode) let hostTeam = try teamIdentifier(for: hostCode) if let pluginTeam, let hostTeam { @@ -548,6 +558,37 @@ struct MacOfficialPluginSignatureVerifier: PluginPackageSignatureVerifying { } } + private func verifyPublisherPackageSignature( + packageAt packageURL: URL, + manifest: PluginManifest + ) throws { + let signatureURL = packageURL.appendingPathComponent(PluginPackageSignature.signatureFileName) + guard FileManager.default.fileExists(atPath: signatureURL.path) else { + throw PluginPackageStoreError.missingPackageSignature + } + let document: PluginPackageSignature.Document + do { + document = try PluginPackageSignature.read(from: packageURL) + } catch { + throw PluginPackageStoreError.invalidPackageSignature("The signature document could not be decoded.") + } + guard let publicKeyData = Data(base64Encoded: PluginPackageSignature.publisherPublicKeyBase64), + let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData) else { + throw PluginPackageStoreError.invalidPackageSignature("The embedded public key is invalid.") + } + do { + try PluginPackageSignature.verify( + packageAt: packageURL, + pluginID: manifest.id.rawValue, + pluginVersion: manifest.version.description, + document: document, + publicKey: publicKey + ) + } catch { + throw PluginPackageStoreError.invalidPackageSignature(error.localizedDescription) + } + } + private func staticCode(at url: URL) throws -> SecStaticCode { var code: SecStaticCode? guard SecStaticCodeCreateWithPath(url as CFURL, [], &code) == errSecSuccess, diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index f579620f3..ebf5a9d21 100644 --- a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -212,6 +212,11 @@ public enum OfficialPluginCatalog { private static let goLanguageID = "go" private static let phpLanguageID = "php" public static let phpPluginID = PluginID("dev.lithe.plugin.php-support") + public static let publisherPackageVendor = PluginVendor( + id: BuiltInPluginCatalog.vendor.id, + displayName: BuiltInPluginCatalog.vendor.displayName, + signatureRequirement: .publisherPackage + ) public static let manifests: [PluginManifest] = [ PluginManifest( @@ -276,7 +281,7 @@ public enum OfficialPluginCatalog { minimum: BuiltInPluginCatalog.hostVersion, maximumExclusive: PluginVersion(major: 0, minor: 4, patch: 0) ), - vendor: BuiltInPluginCatalog.vendor, + vendor: publisherPackageVendor, entrypoint: PluginEntrypoint( kind: .nativeBundle, bundleIdentifier: "dev.lithe.plugin.php-support.bundle", diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index 811a1080e..c897a5f75 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -99,6 +99,7 @@ public struct PluginHostCompatibility: Equatable, Codable, Sendable { public enum PluginSignatureRequirement: String, Codable, Sendable { case sameTeamAsHost + case publisherPackage } public struct PluginVendor: Equatable, Codable, Sendable { diff --git a/macos/Sources/LithePluginPackageSigning/PluginPackageSignature.swift b/macos/Sources/LithePluginPackageSigning/PluginPackageSignature.swift new file mode 100644 index 000000000..8d8a2ff94 --- /dev/null +++ b/macos/Sources/LithePluginPackageSigning/PluginPackageSignature.swift @@ -0,0 +1,242 @@ +import CryptoKit +import Foundation + +/// Signs and verifies the complete file tree of an official native plugin package. +/// +/// The package signature authenticates the release artifact independently from +/// Apple's code-signing identity. Native bundle validation remains required so +/// malformed or unsigned bundles cannot be loaded. +public enum PluginPackageSignature { + public static let schemaVersion = 1 + public static let algorithm = "ed25519" + public static let keyID = "lithe-official-plugins-v1" + /// Base64-encoded public key trusted for official separately distributed plugins. + public static let publisherPublicKeyBase64 = "5g83oIZu4TjOQr5g9KJcrNd2pgdXyEnvhJIXtrPoPyw=" + public static let signatureFileName = "lithe-plugin-signature.json" + + public struct Document: Codable, Equatable, Sendable { + public let schemaVersion: Int + public let algorithm: String + public let keyID: String + public let pluginID: String + public let pluginVersion: String + public let files: [String: String] + public let signature: String + + public init( + schemaVersion: Int = PluginPackageSignature.schemaVersion, + algorithm: String = PluginPackageSignature.algorithm, + keyID: String = PluginPackageSignature.keyID, + pluginID: String, + pluginVersion: String, + files: [String: String], + signature: String + ) { + self.schemaVersion = schemaVersion + self.algorithm = algorithm + self.keyID = keyID + self.pluginID = pluginID + self.pluginVersion = pluginVersion + self.files = files + self.signature = signature + } + } + + public enum Error: Swift.Error, Equatable, LocalizedError { + case invalidPackageRoot + case missingManifest + case invalidManifest + case unsupportedFile(String) + case fileListMismatch + case fileDigestMismatch(String) + case invalidDocument + case invalidSignature + case publicKeyUnavailable + + public var errorDescription: String? { + switch self { + case .invalidPackageRoot: + return "The plugin package root is invalid." + case .missingManifest: + return "The plugin package manifest is missing." + case .invalidManifest: + return "The plugin package manifest is invalid." + case .unsupportedFile(let path): + return "The plugin package contains an unsupported file: \(path)." + case .fileListMismatch: + return "The plugin package file list does not match its signature." + case .fileDigestMismatch(let path): + return "The plugin package file was changed after signing: \(path)." + case .invalidDocument: + return "The plugin package signature document is invalid." + case .invalidSignature: + return "The plugin package signature is invalid." + case .publicKeyUnavailable: + return "The official plugin signing key is unavailable." + } + } + } + + public static func makeDocument( + packageAt packageURL: URL, + pluginID: String, + pluginVersion: String, + privateKey: Curve25519.Signing.PrivateKey, + fileManager: FileManager = .default + ) throws -> Document { + let files = try fileDigests( + packageAt: packageURL, + fileManager: fileManager + ) + let payload = canonicalPayload( + pluginID: pluginID, + pluginVersion: pluginVersion, + files: files + ) + return Document( + pluginID: pluginID, + pluginVersion: pluginVersion, + files: files, + signature: try privateKey.signature(for: payload).base64EncodedString() + ) + } + + public static func verify( + packageAt packageURL: URL, + pluginID: String, + pluginVersion: String, + document: Document, + publicKey: Curve25519.Signing.PublicKey, + fileManager: FileManager = .default + ) throws { + guard document.schemaVersion == schemaVersion, + document.algorithm == algorithm, + document.keyID == keyID, + document.pluginID == pluginID, + document.pluginVersion == pluginVersion, + let signature = Data(base64Encoded: document.signature), + signature.count == 64 else { + throw Error.invalidDocument + } + + let actualFiles = try fileDigests( + packageAt: packageURL, + fileManager: fileManager + ) + guard Set(actualFiles.keys) == Set(document.files.keys) else { + throw Error.fileListMismatch + } + for path in actualFiles.keys.sorted() { + guard actualFiles[path] == document.files[path] else { + throw Error.fileDigestMismatch(path) + } + } + + let payload = canonicalPayload( + pluginID: document.pluginID, + pluginVersion: document.pluginVersion, + files: document.files + ) + guard publicKey.isValidSignature(signature, for: payload) else { + throw Error.invalidSignature + } + } + + public static func write( + _ document: Document, + to packageURL: URL, + fileManager: FileManager = .default + ) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(document) + try data.write( + to: packageURL.appendingPathComponent(signatureFileName), + options: [.atomic] + ) + } + + public static func read( + from packageURL: URL, + fileManager: FileManager = .default + ) throws -> Document { + let signatureURL = packageURL.appendingPathComponent(signatureFileName) + guard fileManager.fileExists(atPath: signatureURL.path) else { + throw Error.invalidDocument + } + do { + return try JSONDecoder().decode(Document.self, from: Data(contentsOf: signatureURL)) + } catch { + throw Error.invalidDocument + } + } + + private static func fileDigests( + packageAt packageURL: URL, + fileManager: FileManager + ) throws -> [String: String] { + let root = packageURL.standardizedFileURL.resolvingSymlinksInPath() + guard (try? root.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { + throw Error.invalidPackageRoot + } + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: [] + ) else { + throw Error.invalidPackageRoot + } + + var digests: [String: String] = [:] + for case let candidate as URL in enumerator { + let values = try candidate.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + let resolvedCandidate = candidate.resolvingSymlinksInPath() + let relativePath = resolvedCandidate.path.replacingOccurrences(of: root.path + "/", with: "") + guard !relativePath.isEmpty else { continue } + guard values.isSymbolicLink != true else { + throw Error.unsupportedFile(relativePath) + } + if values.isDirectory == true { continue } + guard relativePath != signatureFileName else { continue } + guard !relativePath.hasPrefix("/"), + !relativePath.split(separator: "/", omittingEmptySubsequences: false).contains(".."), + !relativePath.contains("\0"), + !relativePath.contains("\n"), + !relativePath.contains("\r") else { + throw Error.unsupportedFile(relativePath) + } + let data = try Data(contentsOf: resolvedCandidate, options: [.mappedIfSafe]) + let digest = SHA256.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() + digests[relativePath] = digest + } + return digests + } + + private static func canonicalPayload( + pluginID: String, + pluginVersion: String, + files: [String: String] + ) -> Data { + var lines = [ + "schemaVersion=\(schemaVersion)", + "algorithm=\(algorithm)", + "keyID=\(keyID)", + "pluginID=\(token(pluginID))", + "pluginVersion=\(token(pluginVersion))" + ] + lines.append(contentsOf: files.keys.sorted().map { path in + "file=\(token(path))=\(files[path]!)" + }) + return Data((lines.joined(separator: "\n") + "\n").utf8) + } + + private static func token(_ value: String) -> String { + value.data(using: .utf8)! + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/macos/Tests/LitheTests/PluginPackageSignatureTests.swift b/macos/Tests/LitheTests/PluginPackageSignatureTests.swift new file mode 100644 index 000000000..b43925877 --- /dev/null +++ b/macos/Tests/LitheTests/PluginPackageSignatureTests.swift @@ -0,0 +1,101 @@ +import CryptoKit +import Foundation +import LithePluginPackageSigning +import Testing + +struct PluginPackageSignatureTests { + @Test + func signatureCoversEveryPackageFile() throws { + let root = try makePackage() + defer { try? FileManager.default.removeItem(at: root) } + let privateKey = try #require( + try? Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 1, count: 32)) + ) + let document = try PluginPackageSignature.makeDocument( + packageAt: root, + pluginID: "dev.example.plugin", + pluginVersion: "0.3.0", + privateKey: privateKey + ) + try PluginPackageSignature.write(document, to: root) + + try PluginPackageSignature.verify( + packageAt: root, + pluginID: "dev.example.plugin", + pluginVersion: "0.3.0", + document: document, + publicKey: privateKey.publicKey + ) + } + + @Test + func tamperingWithAPluginFileIsRejected() throws { + let root = try makePackage() + defer { try? FileManager.default.removeItem(at: root) } + let privateKey = try #require( + try? Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 2, count: 32)) + ) + let document = try PluginPackageSignature.makeDocument( + packageAt: root, + pluginID: "dev.example.plugin", + pluginVersion: "0.3.0", + privateKey: privateKey + ) + try PluginPackageSignature.write(document, to: root) + try Data("changed".utf8).write( + to: root.appendingPathComponent("Example.bundle/Contents/MacOS/plugin") + ) + + #expect(throws: PluginPackageSignature.Error.fileDigestMismatch( + "Example.bundle/Contents/MacOS/plugin" + )) { + try PluginPackageSignature.verify( + packageAt: root, + pluginID: "dev.example.plugin", + pluginVersion: "0.3.0", + document: document, + publicKey: privateKey.publicKey + ) + } + } + + @Test + func addingAFileAfterSigningIsRejected() throws { + let root = try makePackage() + defer { try? FileManager.default.removeItem(at: root) } + let privateKey = try #require( + try? Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 3, count: 32)) + ) + let document = try PluginPackageSignature.makeDocument( + packageAt: root, + pluginID: "dev.example.plugin", + pluginVersion: "0.3.0", + privateKey: privateKey + ) + try PluginPackageSignature.write(document, to: root) + try Data("unexpected".utf8).write(to: root.appendingPathComponent("unexpected.txt")) + + #expect(throws: PluginPackageSignature.Error.fileListMismatch) { + try PluginPackageSignature.verify( + packageAt: root, + pluginID: "dev.example.plugin", + pluginVersion: "0.3.0", + document: document, + publicKey: privateKey.publicKey + ) + } + } + + private func makePackage() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-plugin-signature-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("Example.bundle/Contents/MacOS/plugin") + try FileManager.default.createDirectory( + at: executable.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("manifest".utf8).write(to: root.appendingPathComponent("plugin.json")) + try Data("binary".utf8).write(to: executable) + return root + } +} diff --git a/macos/Tools/LithePluginPackageSigner/main.swift b/macos/Tools/LithePluginPackageSigner/main.swift new file mode 100644 index 000000000..035dd9d26 --- /dev/null +++ b/macos/Tools/LithePluginPackageSigner/main.swift @@ -0,0 +1,80 @@ +import CryptoKit +import Foundation +import LithePluginPackageSigning + +@main +struct LithePluginPackageSigner { + static func main() throws { + guard CommandLine.arguments.count == 2 || + (CommandLine.arguments.count == 3 && CommandLine.arguments[1] == "--verify") else { + throw SignerError.usage + } + let isVerification = CommandLine.arguments.count == 3 + let packageURL = URL( + fileURLWithPath: CommandLine.arguments[isVerification ? 2 : 1], + isDirectory: true + ) + let manifest = try loadManifest(from: packageURL) + + if isVerification { + let document = try PluginPackageSignature.read(from: packageURL) + guard let publicKeyData = Data(base64Encoded: PluginPackageSignature.publisherPublicKeyBase64), + let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData) else { + throw SignerError.invalidPublicKey + } + try PluginPackageSignature.verify( + packageAt: packageURL, + pluginID: manifest.pluginID, + pluginVersion: manifest.pluginVersion, + document: document, + publicKey: publicKey + ) + print("Verified publisher signature for \(manifest.pluginID) \(manifest.pluginVersion)") + return + } + + let keyText = String( + data: FileHandle.standardInput.readDataToEndOfFile(), + encoding: .utf8 + )?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let keyText, + let keyData = Data(base64Encoded: keyText), + let privateKey = try? Curve25519.Signing.PrivateKey(rawRepresentation: keyData) else { + throw SignerError.invalidPrivateKey + } + guard privateKey.publicKey.rawRepresentation.base64EncodedString() + == PluginPackageSignature.publisherPublicKeyBase64 else { + throw SignerError.untrustedPrivateKey + } + + let document = try PluginPackageSignature.makeDocument( + packageAt: packageURL, + pluginID: manifest.pluginID, + pluginVersion: manifest.pluginVersion, + privateKey: privateKey + ) + try PluginPackageSignature.write(document, to: packageURL) + print("Signed publisher package \(manifest.pluginID) \(manifest.pluginVersion)") + } + + private static func loadManifest(from packageURL: URL) throws -> (pluginID: String, pluginVersion: String) { + let manifestURL = packageURL.appendingPathComponent("plugin.json") + guard let manifestData = try? Data(contentsOf: manifestURL), + let manifest = try? JSONSerialization.jsonObject(with: manifestData) as? [String: Any], + let pluginID = manifest["id"] as? String, + let pluginVersion = manifest["version"] as? String, + !pluginID.isEmpty, + !pluginVersion.isEmpty else { + throw SignerError.invalidManifest + } + return (pluginID, pluginVersion) + } +} + +private enum SignerError: Error { + case usage + case invalidPrivateKey + case untrustedPrivateKey + case invalidPublicKey + case invalidManifest +} diff --git a/scripts/build-official-plugins.sh b/scripts/build-official-plugins.sh index 369f3b35f..3333c7d05 100755 --- a/scripts/build-official-plugins.sh +++ b/scripts/build-official-plugins.sh @@ -31,13 +31,17 @@ case "$TRIPLE" in *) print -u2 -- "Unsupported macOS Swift triple: $TRIPLE"; exit 2 ;; esac -BUILD_DIR="$ROOT_DIR/.build/$TRIPLE/$CONFIGURATION" -if [[ -e "$BUILD_DIR/Modules/LitheModuleAPI.swiftmodule" && \ - -e "$BUILD_DIR/Modules/LitheCoreContracts.swiftmodule" ]]; then - MODULE_DIR="$BUILD_DIR/Modules" +SWIFT_LAYOUT_ARGS=( + --triple "$TRIPLE" + --configuration "$CONFIGURATION" +) +SWIFT_BIN_PATH=$(swift build --show-bin-path "${SWIFT_LAYOUT_ARGS[@]}") +if [[ -e "$SWIFT_BIN_PATH/Modules/LitheModuleAPI.swiftmodule" && \ + -e "$SWIFT_BIN_PATH/Modules/LitheCoreContracts.swiftmodule" ]]; then + MODULE_DIR="$SWIFT_BIN_PATH/Modules" else # SwiftPM 6.4 places package modules directly beside the executable. - MODULE_DIR="$BUILD_DIR" + MODULE_DIR="$SWIFT_BIN_PATH" fi if [[ ! -e "$MODULE_DIR/LitheModuleAPI.swiftmodule" || ! -e "$MODULE_DIR/LitheCoreContracts.swiftmodule" ]]; then print -u2 -- "Build Lithe for $TRIPLE ($CONFIGURATION) before packaging official plugins" @@ -45,7 +49,7 @@ if [[ ! -e "$MODULE_DIR/LitheModuleAPI.swiftmodule" || ! -e "$MODULE_DIR/LitheCo fi if [[ -z "$OUTPUT_DIR" ]]; then - OUTPUT_DIR="$BUILD_DIR/OfficialPlugins" + OUTPUT_DIR="$SWIFT_BIN_PATH/OfficialPlugins" fi SDK_PATH=$(/usr/bin/xcrun --sdk macosx --show-sdk-path) if ! SWIFT_COMPILER=$(command -v swiftc); then @@ -59,6 +63,7 @@ for stale_package in "$OUTPUT_DIR"/*(/N); do rm -rf "$stale_package" done matched=0 +signer_binary="" for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do manifest="$plugin_source/plugin.json" info_plist="$plugin_source/Info.plist" @@ -80,6 +85,7 @@ for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do fi bundle_name=$(/usr/bin/plutil -extract entrypoint.bundlePath raw "$manifest") executable_name=$(/usr/bin/plutil -extract CFBundleExecutable raw "$info_plist") + signature_requirement=$(/usr/bin/plutil -extract vendor.signatureRequirement raw "$manifest") package_dir="$OUTPUT_DIR/$package_id" bundle_dir="$package_dir/$bundle_name" executable_dir="$bundle_dir/Contents/MacOS" @@ -117,6 +123,32 @@ for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do fi /usr/bin/codesign --force --sign "$SIGNING_IDENTITY" "$bundle_dir" + + if [[ "$signature_requirement" == "publisherPackage" ]]; then + if [[ -z "${LITHE_PLUGIN_PACKAGE_PRIVATE_KEY:-}" ]]; then + if [[ "$CONFIGURATION" == "release" ]]; then + print -u2 -- "Configure LITHE_PLUGIN_PACKAGE_PRIVATE_KEY for publisher-signed plugin packages" + exit 1 + fi + print -u2 -- "Skipping publisher signature for debug plugin package $package_id" + continue + fi + if [[ -z "$signer_binary" ]]; then + swift build \ + "${SWIFT_LAYOUT_ARGS[@]}" \ + --product LithePluginPackageSigner + signer_bin_dir=$(swift build \ + "${SWIFT_LAYOUT_ARGS[@]}" \ + --show-bin-path) + signer_binary="$signer_bin_dir/LithePluginPackageSigner" + [[ -x "$signer_binary" ]] || { + print -u2 -- "Plugin package signer was not built: $signer_binary" + exit 1 + } + fi + print -rn -- "$LITHE_PLUGIN_PACKAGE_PRIVATE_KEY" | "$signer_binary" "$package_dir" + "$signer_binary" --verify "$package_dir" + fi done if (( matched == 0 )); then diff --git a/scripts/verify-shared-contracts.sh b/scripts/verify-shared-contracts.sh index 007188b89..925f6b0a7 100755 --- a/scripts/verify-shared-contracts.sh +++ b/scripts/verify-shared-contracts.sh @@ -143,7 +143,7 @@ fi abort "plugin module IDs must be unique" unless owned_modules.uniq.length == owned_modules.length entries.each do |plugin| abort "plugin API mismatch" unless plugin.fetch("apiVersion") == plugins.fetch("pluginAPIVersion") - abort "official plugin signature policy mismatch" unless plugin.fetch("vendor").fetch("signatureRequirement") == "sameTeamAsHost" + abort "official plugin signature policy mismatch" unless %w[publisherPackage sameTeamAsHost].include?(plugin.fetch("vendor").fetch("signatureRequirement")) abort "plugin module IDs must be sorted" unless plugin.fetch("moduleIDs") == plugin.fetch("moduleIDs").sort end ' "$plugin_fixture" diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 2e75a51bc..7991962fa 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -1,6 +1,6 @@ { "schemaVersion": 3, - "lastReviewed": "2026-09-29", + "lastReviewed": "2026-10-01", "review": { "status": "initial-static-inventory", "method": "根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。", @@ -2749,6 +2749,35 @@ "owner": "PHP Support", "verification": "macOS 运行 PHP 文件和单条/整套 PHPUnit;Windows 运行字符串/数组 Composer script 和整套 PHPUnit。禁用后菜单消失、运行进程退出,特殊文件名保持原样。Windows 暂不支持单方法发现。" }, + { + "id": "macos-php-plugin-publisher-signature", + "area": "发布与安装", + "group": "官方插件", + "capability": "独立 PHP 插件包使用 Lithe publisher 签名并可发布到 GitHub Release", + "macos": { + "evidence": [ + "macos/Sources/LithePluginPackageSigning/PluginPackageSignature.swift", + "macos/Tools/LithePluginPackageSigner/main.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift", + "scripts/build-official-plugins.sh", + ".github/workflows/release-macos.yml", + ".github/workflows/release-preview-macos.yml", + "Plugins/mac/Official/PhpSupport/plugin.json" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "Plugins/win/Official/PhpSupport/plugin.ts", + "windows/tauri/src/extensions/packages/local-extension-package.ts" + ], + "implementationStatus": "platform-specific", + "verificationStatus": "not-applicable" + }, + "owner": "Release", + "verification": "macOS 构建配置 LITHE_PLUGIN_PACKAGE_PRIVATE_KEY,在 arm64 和 x86_64 生成 PHP zip;解压后验证签名文档覆盖完整文件树,篡改任意文件、manifest、版本或签名后确认插件安装被拒绝,并从 GitHub Release 下载包完成一次安装。Windows 使用独立本地插件包流程,本能力不适用。" + }, { "id": "git-workspace-staged-commit", "area": "版本控制", From 54c6d8ae7ed583fd649b433ba07cc01158ba0e89 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 1 Oct 2026 09:54:39 +0800 Subject: [PATCH 5/5] feat: move PHP LSP contract lifecycle into Rust core --- ...09-30-lsp-plugin-build-and-distribution.md | 24 + .../Module/PhpExecutionModule.swift | 4 +- .../Module/PhpLanguageServerModule.swift | 4 +- .../Support/PhpSupportManifest.swift | 74 ++ .../Official/PhpSupport/language-server.json | 3 + .../language-plugin-tooling-requirements.md | 702 ++++++++++++++++++ docs/development/platform-parity-matrix.csv | 3 +- docs/development/platform-parity-matrix.md | 11 +- ...PluginLanguageServerPackageValidator.swift | 98 ++- .../Plugins/MacPluginPackageDownloader.swift | 5 +- .../MacOS/Plugins/MacPluginPackageStore.swift | 4 + .../LitheTests/PluginPackageStoreTests.swift | 35 + .../LitheTests/RealPhpIntegrationTests.swift | 205 ++++- rust/lithe-core/src/plugins/mod.rs | 627 +++++++++++++++- rust/lithe-core/src/protocol/command.rs | 9 + rust/lithe-core/src/runtime/dispatcher.rs | 110 +++ rust/lithe-core/src/tests/plugins.rs | 251 ++++++- scripts/test-rust-core-comments.mjs | 5 +- shared/contracts/rust-core-api.md | 60 ++ shared/fixtures/plugins/lifecycle-v1.json | 69 ++ shared/platform-feature-matrix.json | 30 +- 21 files changed, 2267 insertions(+), 66 deletions(-) create mode 100644 Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Support/PhpSupportManifest.swift create mode 100644 docs/development/language-plugin-tooling-requirements.md create mode 100644 shared/fixtures/plugins/lifecycle-v1.json 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 7d1da2395..e9f30f49a 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 @@ -8,6 +8,12 @@ 插件拥有语言服务器的文件、启动入口和生命周期;Lithe 只负责插件包验证、插件启停、运行时发现和现有 LSP 会话编排。运行时资源写入用户级 Application Support、Caches 或临时目录,插件卸载、重装和回滚必须能够连同自己的语言服务器一起清理或替换。 +本阶段把这条边界进一步落实到 Rust Core:`plugin.validateManifest`、 +`plugin.validateLanguageServer` 和 `plugin.lifecycle` 现在是跨平台可执行的 +JSON 契约。macOS 包管理器在加载带语言服务器的插件前调用这些命令;Swift +只负责读取文件、检查插件包内的可执行入口和把结果投影到安装流程。PHP +Support 是第一份实际接入,Windows 仍待迁移到同一组命令。 + ## 问题 语言服务器通常包含可执行入口、第三方归档、许可证和平台相关资源。把这些文件直接放进主程序,或者让主程序在运行时自行下载,会带来几个问题:不使用该语言的用户承担下载和索引成本;插件签名边界不完整;语言服务器可能写入安装目录,破坏 app bundle 的发布基线;插件卸载后还可能留下脱离插件的运行时。 @@ -22,6 +28,11 @@ PHP Support 已经验证了独立插件包、固定 Intelephense 版本和插件 当前 PHP 插件的示例是 `Plugins/mac/Official/PhpSupport/language-server.json`。如果另一个语言服务器使用 zip、单文件可执行程序或不同的启动方式,应扩展构建脚本支持的格式,并保持“固定来源、固定校验、构建后签名”的原则;不能跳过校验直接把网络下载物复制进插件。 +Rust Core 同时校验插件目录的 `plugin.json`:schema/API、严格三段版本、 +宿主兼容区间、entrypoint、模块排序和语言能力到模块的归属。安装包可以使用 +完整 `modules` 声明,Core 也兼容共享 fixture 的 `moduleIDs` 形式;两者都不能 +让一个语言能力引用另一个插件的模块。平台适配器不应重新增加一套接受条件。 + ### 2. 构建阶段完成下载、解压和组装 官方 macOS 插件统一通过 `scripts/build-official-plugins.sh` 构建。该脚本负责: @@ -49,6 +60,17 @@ macOS 当前的用户级目录是 `/Lithe/Plugins//versi LSP 控制中心和语言工具发现沿用现有 Rust Core LSP 会话。插件启用后,组合根把该插件版本目录中的启动器根路径传给平台运行时;插件未安装、被禁用、隔离或等待重启时,不得重新启用宿主内置的旧路径或通用安装器。 +插件状态由 Rust Core 的生命周期 reducer 定义:`discovered`、`installing`、 +`installed`、`enabling`、`enabled`、`disabling`、`disabled`、`uninstalling`、 +`uninstalled` 和 `failed`。LSP session、语言服务器进程、测试运行和其他插件 +资源使用不透明 ID 绑定到该状态;Core 在资源仍存在时拒绝 `disable`、 +`uninstall`、失败和重置。当前 macOS PHP 切片已由 `ModuleRuntime` 将插件模块的 +启用/禁用接到真实 Intelephense session;Rust reducer 仍是无状态 JSON 契约,尚未 +替换 macOS/Windows 的完整 package store 和 session registry。下一阶段必须让 +这些宿主状态提交和资源绑定统一消费该 reducer,而不是把 reducer 的存在误报为 +全产品生命周期迁移完成。UI 的“保留语言服务器”只能保留经过版本/校验确认的 +插件资源记录,不能把进程或下载物转移到宿主共享目录。 + 语言符号、诊断、补全和类型分析继续由上游语言服务器负责。Lithe 只拥有会话生命周期、取消、超时、旧结果保护、资源预算和稳定的跨平台适配契约,不在 Core、Swift 和 Windows 前端各自实现第二套语言语义。 ### 5. 运行时资源必须离开安装目录 @@ -78,6 +100,8 @@ macOS 使用原生 bundle 和 Developer ID 签名;Windows 使用自己的 Taur ## 验证 +- `cargo test -p lithe-core tests::plugins --no-fail-fast`:验证 PHP 清单、语言服务器清单、dispatcher 命令和资源阻塞的生命周期;生命周期 wire fixture 位于 `shared/fixtures/plugins/lifecycle-v1.json`。 +- `scripts/test-macos.sh --filter 'PluginPackageStoreTests|RealPhpIntegrationTests'`:验证 macOS 包管理器通过 Rust Core 校验,并在有 Intelephense 时执行真实 PHP LSP 全流程。 - `./scripts/verify-official-plugins.sh`:检查官方插件构建、语言服务器清单、启动器和代码签名。 - `./scripts/verify-runtime-bundle-immutability.sh`:确认典型启动和插件工作流不会改写已安装 bundle。 - `node scripts/test-reuse-worktree-resources.mjs`:确认语言服务器下载和插件包不能跨工作树复用。 diff --git a/Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Module/PhpExecutionModule.swift b/Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Module/PhpExecutionModule.swift index 0159f5b6d..4e578eea2 100644 --- a/Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Module/PhpExecutionModule.swift +++ b/Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Module/PhpExecutionModule.swift @@ -3,9 +3,7 @@ import LitheModuleAPI @MainActor public final class PhpExecutionModule: LitheModule { - public static let moduleManifest = OfficialPluginCatalog.moduleManifest( - for: .languageExecutionExtension(phpLanguageID) - )! + public static let moduleManifest = PhpSupportManifest.executionModule public let manifest = moduleManifest private let executionHost: (any LanguageExecutionHostProviding)? diff --git a/Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Module/PhpLanguageServerModule.swift b/Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Module/PhpLanguageServerModule.swift index 3a7c7254d..fb7721cce 100644 --- a/Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Module/PhpLanguageServerModule.swift +++ b/Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Module/PhpLanguageServerModule.swift @@ -3,9 +3,7 @@ import LitheModuleAPI @MainActor public final class PhpLanguageServerModule: LitheModule { - public static let moduleManifest = OfficialPluginCatalog.moduleManifest( - for: .languageServerExtension(phpLanguageID) - )! + public static let moduleManifest = PhpSupportManifest.languageServerModule public let manifest = moduleManifest private var capability: PhpLanguageServerCapability? diff --git a/Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Support/PhpSupportManifest.swift b/Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Support/PhpSupportManifest.swift new file mode 100644 index 000000000..86379fa8e --- /dev/null +++ b/Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Support/PhpSupportManifest.swift @@ -0,0 +1,74 @@ +import LitheModuleAPI + +/// The PHP package owns its manifest and module declarations. +/// +/// This is deliberately kept under `Plugins/mac/Official/PhpSupport`; the +/// application catalog only discovers the decoded package manifest. Keeping the +/// declaration here prevents the plugin from importing a host language catalog +/// or another language's implementation during an isolated package build. +public enum PhpSupportManifest { + public static let pluginID = PluginID("dev.lithe.plugin.php-support") + public static let version = PluginVersion(major: 0, minor: 3, patch: 0) + public static let languageID = "php" + + public static let vendor = PluginVendor( + id: "dev.lithe", + displayName: "Lithe", + signatureRequirement: .publisherPackage + ) + + public static let executionModule = ModuleManifest( + id: .languageExecutionExtension(languageID), + displayName: "PHP Execution", + scope: .workspace, + defaultState: .disabled, + activationPolicy: .onDemand, + sleepPolicy: .whenIdle(afterSeconds: 10 * 60), + dependencies: [.module(.workspace)], + providedCapabilities: [ + .languageExecutionExtension(languageID), + .languageTestingExtension(languageID) + ] + ) + + public static let languageServerModule = ModuleManifest( + id: .languageServerExtension(languageID), + displayName: "PHP Language Server", + scope: .workspace, + defaultState: .disabled, + activationPolicy: .onDemand, + sleepPolicy: .whenIdle(afterSeconds: 10 * 60), + dependencies: [.module(.workspace)], + providedCapabilities: [.languageServerExtension(languageID)] + ) + + public static let plugin = PluginManifest( + id: pluginID, + displayName: "PHP Support", + version: version, + hostCompatibility: PluginHostCompatibility( + minimum: version, + maximumExclusive: PluginVersion(major: 0, minor: 4, patch: 0) + ), + vendor: vendor, + entrypoint: PluginEntrypoint( + kind: .nativeBundle, + bundleIdentifier: "dev.lithe.plugin.php-support.bundle", + principalClass: "LithePhpSupportPluginEntrypoint", + bundlePath: "PhpSupport.bundle" + ), + modules: [ + PluginModuleDeclaration(manifest: executionModule), + PluginModuleDeclaration(manifest: languageServerModule) + ], + languageSupports: [LanguageSupportDeclaration( + id: languageID, + displayName: "PHP", + fileExtensions: ["php", "phtml"], + projectFileNames: ["composer.json"], + languageServerModuleID: languageServerModule.id, + executionModuleID: executionModule.id, + testingModuleID: executionModule.id + )] + ) +} diff --git a/Plugins/mac/Official/PhpSupport/language-server.json b/Plugins/mac/Official/PhpSupport/language-server.json index 15f9ce72c..f120ac761 100644 --- a/Plugins/mac/Official/PhpSupport/language-server.json +++ b/Plugins/mac/Official/PhpSupport/language-server.json @@ -1,12 +1,15 @@ { "schemaVersion": 1, "pluginID": "dev.lithe.plugin.php-support", + "languageID": "php", "toolID": "intelephense", "version": "1.15.1", "archiveURL": "https://registry.npmjs.org/intelephense/-/intelephense-1.15.1.tgz", "archiveSHA256": "24a33fe3cd7a2382f44285e2ae553a7e1c898cbc208ee20cee0693497ee9ebe2", "archiveFormat": "tarGzip", "archiveRoot": "package", + "launcherRelativePath": "bin/intelephense", + "arguments": ["--stdio"], "entrypoint": "lib/intelephense.js", "license": "LICENSE.txt" } diff --git a/docs/development/language-plugin-tooling-requirements.md b/docs/development/language-plugin-tooling-requirements.md new file mode 100644 index 000000000..93bb87c7e --- /dev/null +++ b/docs/development/language-plugin-tooling-requirements.md @@ -0,0 +1,702 @@ +# 语言插件、SDK、LSP 与构建工具需求文档 + +状态:提案需求基线,目标为完整迁移 +日期:2026-09-30 +适用范围:macOS、Windows、Rust Core、官方语言插件和插件管理界面 + +## 1. 文档目的 + +本文把语言支持相关需求整理成一份可实现、可评审、可验收的基线,覆盖: + +- 插件界面中的 SDK、工具链和语言服务器配置; +- 语言服务器与插件的资源所有权和生命周期; +- 构建、运行、测试、格式化和调试工具的归属; +- 基础语言导航和后续语言智能能力; +- 下载、更新、卸载、安全、跨平台和测试要求。 + +本文是产品需求和实现边界的汇总,不替代已经落地的 LSP Runtime、模块生命周期和插件分发决策。相关架构依据见文末参考资料。 + +## 2. 背景与问题 + +语言支持通常由多个相互依赖的组件组成:SDK 或运行时、语言服务器、构建工具、包管理器、测试框架和调试适配器。若这些组件由宿主应用分别发现和管理,会产生几个问题: + +1. 用户不知道当前语言服务实际使用的是哪个 SDK、哪个 LSP 版本和哪个构建工具。 +2. 插件禁用或卸载后,语言服务器进程、缓存和下载物可能继续存在,形成脱离插件的资源。 +3. 宿主为每种语言维护一套下载、启动和构建逻辑,导致插件之间的行为不一致。 +4. LSP、构建工具和编辑器使用不同的项目根目录或工具链,跳转、构建和运行结果可能互相矛盾。 +5. macOS 和 Windows 各自实现一套生命周期时,文档同步、取消、超时和旧结果保护容易产生差异。 + +因此,语言支持应以“语言插件包”为产品和资源边界,以 Rust Core 的通用 LSP Runtime 为进程和协议边界,以平台适配器负责本机路径、下载、存储和进程启动。 + +## 3. 目标 + +### 3.1 用户目标 + +用户可以在插件详情页完成以下操作: + +- 查看插件支持的语言、SDK、LSP、构建、测试和调试能力; +- 自动检测本机已有工具; +- 选择 SDK、LSP 或构建工具路径; +- 下载官方或插件声明的兼容版本; +- 查看版本、来源、校验和当前健康状态; +- 切换版本、重新验证、重启服务和清理托管资源; +- 在插件禁用或卸载时明确决定是否保留可独立管理的 SDK/LSP 资源。 + +### 3.2 工程目标 + +- 语言服务器由 Rust Core 统一管理进程、协议、文档同步、请求期限、诊断和崩溃恢复。 +- 最终由 Rust Core 拥有全部跨平台公共契约的可执行语义;Swift 和 TypeScript 不再维护同一契约的第二份手写真源。 +- 插件拥有其声明的语言资源、工具链和功能模块;宿主不维护第二套通用语言服务器目录。 +- 构建、运行、测试和调试能力默认与对应语言插件绑定;Rust 是明确的内置语言特例。 +- 两个平台消费相同的能力语义和稳定错误,不共享平台实现代码。 +- 运行时不修改已安装 app bundle 或 Windows 安装目录。 + +### 3.3 非目标 + +本阶段不包括: + +- 为每种语言重新实现完整编译器、语义索引或类型系统; +- 允许项目文件执行任意下载命令、shell 命令或安装脚本; +- 把所有宿主功能都改造成可下载插件; +- 在本阶段设计远程开发、容器开发或云端语言服务器协议; +- 为了提供同一功能而复制上游语言服务器已有的项目模型。 + +## 4. 核心决策 + +### 4.1 插件是语言能力的归属单位 + +一个语言插件可以声明以下能力: + +| 能力 | 作用 | +| --- | --- | +| `languageServer` | 补全、诊断、跳转、重命名等语言智能 | +| `toolchain` | SDK、编译器、解释器或运行时 | +| `execution` | 运行当前文件、目标或项目 | +| `build` | 构建项目、模块或目标 | +| `testing` | 发现和执行测试 | +| `formatting` | 文档和选区格式化 | +| `debugAdapter` | 调试会话和断点 | +| `projectModel` | 项目文件、依赖和源代码根解析 | + +这些模块可以独立按需激活,但同一语言的模块资源和生命周期由同一个插件拥有。插件清单中的模块 ID、语言 ID 和能力 ID 是兼容性表面,不能由 UI 临时拼接。 + +### 4.2 官方语言插件的目录归属与旧代码隔离 + +官方语言插件必须把完整的源码归属放在平台对应的 `Plugins` 目录下: + +```text +Plugins/ +├── mac/Official// +│ ├── plugin.json +│ ├── Info.plist(原生 macOS 插件需要) +│ ├── toolchain.json 或 language-server.json +│ ├── Sources/ +│ └── Tests/ +└── win/Official// + ├── manifest 或 plugin.json + ├── SDK/ + ├── sources/ + └── tests/ +``` + +Go Support 的唯一源码归属是 `Plugins/mac/Official/GoSupport/`。它包含 Go 的 manifest、工具链描述、语言服务器配置、模块源码、插件入口和测试。PHP Support 以及后续新增的官方语言插件遵循同一模式;平台不同只改变插件实现和打包方式,不改变目录所有权规则。 + +“对原来部分代码 0 依赖”指插件不能依赖宿主中已经存在的语言实现或旧的 Go 实现,具体包括: + +- 不得从 `macos/Sources/Lithe/`、`macos/Sources/LitheLanguageIntelligenceModule/`、`macos/Sources/LitheExecutionModule/` 或对应 Windows 产品目录导入语言专属实现; +- 不得让宿主的 `AppModel`、语言服务具体实现、旧 capability、旧工具发现器或旧路径成为插件的运行时前置条件; +- 不得在宿主静态 catalog 中复制一份 Go/语言插件的 module manifest、能力实现、工具链路径或 LSP fallback; +- 插件缺失、禁用或卸载后,宿主不能重新启用旧代码路径来“补回”该语言能力; +- `Package.swift`、Vite 或 CI 中的路径只负责构建和测试接线,不能把插件库链接进宿主应用作为内置实现。 + +这里的“0 依赖”不是“0 依赖所有宿主协议”。插件可以依赖稳定的公共接口和宿主服务协议,例如 `LitheModuleAPI`、`LitheCoreContracts`、模块生命周期接口、语言执行 host service 和 Rust Core 的稳定桥接;这些依赖只提供契约和运行时注入,不包含 Go 或某种语言的业务实现。插件测试可以使用通用 `ModuleRuntime` 测试夹具,但不能调用旧语言模块来完成断言。 + +宿主可以保留通用的插件扫描器、manifest 校验器、插件目录管理器、LSP Runtime 和 UI 路由器。它们只能通过已校验的插件 manifest、模块 ID、能力 ID 和公共 host service 与插件交互,不得通过 import、静态链接或语言名称分支重新拥有插件功能。 + +构建产物可以暂时出现在 `.build/.../OfficialPlugins` 或安装目录的用户版本目录中,但那是生成或安装结果;源代码、清单、测试和语言专属构建输入的唯一归属仍是 `Plugins//Official//`。 + +迁移一个旧语言实现时,必须按以下顺序完成: + +1. 把语言专属源码、清单、工具链描述和测试移动到对应的 `Plugins` 子目录。 +2. 将插件对外能力改为公共 host service 和公共语言契约。 +3. 从宿主 catalog、AppModel、旧模块和平台 fallback 中删除语言专属实现。 +4. 让宿主只从已安装插件包读取 manifest 并发现入口。 +5. 增加依赖扫描,验证宿主产品没有链接该插件实现,插件也没有反向依赖宿主应用实现。 +6. 在 macOS 和 Windows 的功能矩阵中记录插件路径、宿主边界和验证证据。 + +### 4.3 公共契约完整下沉到 Rust Core,原生插件保留薄适配层 + +本项目的最终目标是把语言插件需要的公共契约完整下沉到 Rust Core。这正是解决“插件放在 `Plugins` 下、同时不依赖旧宿主实现”的长期方案。迁移对象是**所有稳定的跨平台协议、数据模型和状态语义**;平台专属的原生插件运行时保留为薄适配层,不作为第二套契约真源。 + +适合由 Rust Core 拥有并执行校验的契约包括: + +| 契约 | Rust Core 的职责 | 平台/插件职责 | +| --- | --- | --- | +| `PluginID`、`ModuleID`、版本和兼容范围 | 解析、规范化、比较和序列化 | UI 展示 | +| `plugin.json`、工具链和 LSP manifest | schema 校验、依赖图、能力归属和稳定错误 | 读取安装目录、下载和签名验证 | +| 语言支持声明 | 文件关联、项目文件、能力列表和模块绑定 | 注册 UI 路由 | +| LSP 启动描述 | 参数数组、环境引用、工作区相对路径和版本约束 | 解析本机可执行路径和平台环境 | +| 模块状态、operation ID、取消和事件 | 状态机、资源关系、顺序和旧结果保护 | 映射到 Swift/React 状态 | +| 导航位置、诊断、文本编辑和构建计划 | 坐标规范化、确定性排序、路径安全和错误分类 | 打开文件、渲染结果、启动平台进程 | +| 语言插件能力清单 | 能力协商、依赖检查和不可用原因 | 提供具体 capability 实现 | + +不应直接下沉为 Rust 类型的内容包括: + +- Swift 的 `@MainActor`、`AnyObject`、闭包和原生 `URL` 生命周期; +- macOS Bundle 加载、Windows Worker/Tauri 加载和平台签名 API; +- `ModuleFactory` 的原生闭包、Swift 对象引用和 AppKit/React UI 状态; +- 文件选择器、Application Support、AppData、进程句柄和终端对象。 + +最终分层是: + +```text +Rust Core:Serde DTO + schema 校验 + 状态机 + 稳定 JSON/C ABI(唯一可执行真源) + ↓ +生成的 Swift/TypeScript bindings(只做类型和编码映射,不定义新语义) + ↓ +平台 host adapter(路径、进程、Bundle、存储、签名) + ↓ +Plugins//Official/(语言专属能力) +``` + +`shared/contracts` 仍然保留为跨平台公开契约、fixture 和文档;Rust Core 负责其中可执行的解析、校验、排序和状态语义。不能把契约只藏在 Rust 私有模块中,否则 Swift、Windows 和插件无法独立验证 wire format。 + +具体迁移必须完成以下三层,不能长期停在兼容层: + +1. **第一层:数据契约**。在 Rust 中定义带 `serde` 的 ID、版本、manifest、toolchain、LSP launch、能力、错误和结构化任务计划;通过现有 JSON command/C ABI 暴露。Swift/TypeScript 改为使用生成的 bindings,旧的手写 DTO 在迁移完成后删除,而不是永久保留为第二真源。 +2. **第二层:语言工具运行时**。把 LSP session、文档同步、导航、诊断、取消、构建计划和资源归属集中在 Rust;插件只实现语言特有的 descriptor、参数和 host service 调用。 +3. **第三层:模块生命周期协议**。Rust 拥有 manifest graph、模块状态、资源关系、operation ID 和事件协议;macOS/Windows 保留原生 Bundle/Worker 加载和薄适配层。Rust 不直接加载 Swift Bundle,也不持有 Swift `AnyObject` capability,但所有平台都必须消费 Rust 发布的生命周期协议。 + +完成迁移后,GoSupport 可以只依赖生成的公共插件绑定、`LitheModuleAPI` 的兼容薄层或等价 host ABI,以及注入的公共服务;它不能再依赖 `OfficialPluginCatalog` 中的 Go 条目、宿主语言模块、AppModel 或旧工具发现逻辑。宿主只通过 Rust 校验后的插件 descriptor 和 host service 与它交互。 + +“兼容薄层”只允许作为迁移阶段的临时桥接,必须有删除条件和迁移清单。最终插件不应依赖包含语言业务实现的 `LitheModuleAPI` 或 `LitheCoreContracts` 模块;它应依赖由 Rust 契约生成的插件 ABI/bindings,以及平台注入的 host service 接口。 + +### 4.4 LSP Runtime 只有一个真源 + +编辑器只依赖统一的语言能力接口。Rust Core 负责: + +- LSP 子进程和 stdio transport; +- JSON-RPC framing、请求 ID 和响应匹配; +- `didOpen`、`didChange`、`didClose` 和文档版本; +- capability 协商和动态 capability 变化; +- 请求取消、超时、旧结果丢弃和崩溃恢复; +- 诊断、位置、编辑、completion item 和统一结果 DTO。 + +Swift、TypeScript 和平台适配器只负责工具发现、环境准备、UI 投影和工作区路由,不能各自持有另一套 LSP 会话状态机。 + +### 4.5 工具发现、工具下载和工具运行分层 + +- **发现**:查找用户配置、项目配置、插件托管目录和系统 PATH。 +- **下载**:只允许插件或平台安装器使用固定 HTTPS 来源、版本和校验值。 +- **运行**:只启动已经验证的工具,并通过插件所属模块创建会话。 + +通用语言 provider catalog 可以描述可探测的工具,但不能通过项目文件声明任意安装命令。官方插件的语言服务器可以在构建阶段固定版本并打入签名包,也可以在安装后放入插件用户目录;两者都必须经过校验。 + +### 4.6 Rust 是内置语言特例 + +Rust 的根工具链和 Cargo 强绑定于宿主的语言支持,因此可以保留为内置产品能力。除非有明确的架构理由,其他语言的 SDK、LSP 和构建工具都应归属对应语言插件,不能逐渐扩展成宿主级的通用工具集合。 + +## 5. 插件管理界面 + +### 5.1 插件列表 + +列表显示: + +- 插件名称、版本、供应方和启用状态; +- 支持的语言和文件关联; +- 当前工作区是否使用该插件; +- SDK、LSP 和构建工具的总体状态; +- 是否需要重启、重新验证或修复。 + +### 5.2 插件详情页 + +详情页按能力分组展示: + +1. 语言支持和文件类型; +2. SDK/工具链; +3. LSP; +4. 构建与运行; +5. 测试; +6. 格式化; +7. 调试器; +8. 工作区状态和诊断日志。 + +每一组都应提供“自动检测”“选择路径”“下载兼容版本”“重新验证”“重启服务”或“恢复默认”操作。下载和验证期间显示确定的进度状态,不能只显示无期限的旋转进度。 + +### 5.3 状态模型 + +用户可见状态至少包括: + +| 状态 | 含义 | +| --- | --- | +| 未配置 | 没有找到可用资源 | +| 检测中 | 正在检查路径、版本或项目环境 | +| 下载中 | 正在下载或安装插件托管资源 | +| 已配置 | 路径存在,但尚未完成完整健康检查 | +| 已就绪 | 版本、入口和启动检查均通过 | +| 版本不兼容 | 工具存在,但不满足插件要求 | +| 校验失败 | 下载物、签名或清单校验不通过 | +| 启动失败 | 工具验证通过,但进程或 initialize 失败 | +| 等待重启 | 当前进程仍映射旧插件代码,需要重启生效 | +| 已保留未绑定 | 卸载插件后保留的用户资源,当前不会启动 | + +## 6. SDK、LSP 和构建工具配置 + +### 6.1 来源类型 + +每种工具支持以下来源: + +1. 插件内置的签名版本; +2. 插件用户目录中下载并验证的托管版本; +3. 用户手动选择的外部路径; +4. 项目内已有的相对路径工具; +5. 系统 PATH 或平台标准目录中的工具。 + +用户手动选择的路径只保存为平台本地配置,不进入跨平台项目文件。项目文件只能保存工具链 ID、版本约束和工作区相对路径。 + +### 6.2 选择优先级 + +默认优先级为: + +```text +项目显式配置 + > 用户选择的本机路径 + > 插件托管版本 + > 插件内置版本 + > 系统 PATH/平台自动检测 +``` + +如果显式配置失效,应显示失败原因并提供修复入口。除非用户主动选择自动恢复,否则不能静默跳到另一套版本,避免 LSP 和构建工具使用不同工具链。 + +### 6.3 工具链清单 + +目标 manifest 至少需要描述: + +| 字段 | 作用 | +| --- | --- | +| `pluginID` | 资源所有者 | +| `languageID` | 对应语言 | +| `toolID` | SDK、LSP 或构建工具的稳定 ID | +| `version`/`versionRange` | 固定版本或兼容范围 | +| `platform`/`architecture` | 适用系统和架构 | +| `executable` | 包内或托管目录中的相对入口 | +| `validationArguments` | 版本和健康检查参数 | +| `download` | 固定 HTTPS 地址、归档格式和 SHA-256/签名 | +| `dependencies` | 依赖的 SDK、运行时或其他模块 | +| `license` | 分发所需的许可证信息 | + +Manifest 描述构建输入和已验证资源,不得包含开发者机器路径、用户目录或运行时缓存路径。 + +### 6.4 SDK 下载流程 + +1. 用户在插件页面点击下载或项目请求缺失工具链。 +2. 插件根据平台和架构选择固定版本与下载源。 +3. 下载到临时目录,支持取消并清理未完成文件。 +4. 校验签名或 SHA-256,校验归档根目录和入口文件。 +5. 原子移动到插件用户目录的版本目录。 +6. 执行版本命令和最小健康检查。 +7. 发布 `ready` 状态并允许语言服务器或构建模块使用。 + +失败时保留稳定错误码、失败阶段和可操作的修复建议;不能把任意命令输出直接当作用户提示。 + +### 6.5 LSP 下载和路径配置 + +LSP 路径可以来自内置包、插件托管版本或用户选择路径。LSP 启动前必须验证: + +- 入口是插件声明的可执行文件或脚本; +- 版本满足插件要求; +- 依赖的 SDK 和运行时已准备好; +- 工作目录和参数不包含未校验的路径穿越; +- initialize 响应在规定期限内返回。 + +LSP 的下载和 SDK 下载共用进度、取消、校验和回滚机制,但版本切换不能让已有会话继续引用被替换的文件。切换版本需要停止旧会话,必要时等待重启。 + +## 7. 插件、LSP 和资源生命周期 + +### 7.1 生命周期流程 + +```text +发现项目 + → 匹配语言插件 + → 解析工具链来源 + → 检测/下载 SDK + → 检测/下载 LSP + → 激活插件模块 + → 启动 Rust LSP session + → 同步文档并提供语言能力 + → 空闲休眠或停止 + → 禁用、更新、回滚或卸载 +``` + +插件未安装、被禁用、被隔离或健康检查失败时,对应语言能力不可用,不能回退到宿主的旧路径或另一个通用 LSP。 + +### 7.2 启停不变量 + +- 禁用插件后,必须停止其 LSP、构建、测试、调试、watcher、timer 和子进程。 +- 关闭工作区后,旧工作区的 session、文档、诊断和索引不能影响新工作区。 +- 停止先尝试协议级 shutdown,再在有界期限后强制终止。 +- 插件更新、回滚和卸载涉及已加载的 native bundle 时,在下次启动前完成最终切换。 +- 当前进程无法安全卸载已映射的 native bundle 时,UI 必须明确显示“等待重启”。 +- 语言服务的 session 所属关系必须包含插件、语言和工作区根目录。 + +### 7.3 卸载选项 + +默认卸载动作是删除插件及其托管资源。确认对话框提供: + +- **同时删除插件托管的 SDK/LSP**:删除由插件下载并拥有的版本目录; +- **保留 SDK/LSP**:将资源移动或标记到用户级“已保留未绑定”目录,卸载后不启动; +- **取消**。 + +外部路径、项目内工具和系统安装永远不由插件卸载流程删除。重新安装同一插件时,可以通过版本、校验和 tool ID 重新绑定已保留资源。 + +## 8. 构建、运行、测试和调试 + +### 8.1 绑定原则 + +除 Rust 外,构建工具默认与语言插件绑定。插件可以同时提供 LSP 和构建能力,也可以只提供其中一项,但构建模块必须声明所需的 SDK、项目文件和语言 ID。 + +插件模块可以独立按需激活:用户只编辑代码时不启动构建进程;用户点击构建时才激活 execution/build 模块。独立激活不改变资源所有权。 + +### 8.2 结构化任务计划 + +插件向宿主返回结构化任务计划,而不是拼接 shell 字符串。计划包括: + +- 任务 ID、语言 ID 和插件模块 ID; +- 项目根目录和工作目录; +- 目标、profile、配置和参数数组; +- 工具链引用; +- 环境变量引用; +- 是否需要预构建或依赖解析; +- 输出诊断解析规则; +- 是否支持取消、重试和增量执行。 + +平台适配器负责把计划解析成实际可执行路径、进程环境和终端会话。Core 负责路径约束、参数顺序、依赖关系和稳定结果,不负责拼接平台专属的路径分隔符或 shell 命令。 + +### 8.3 构建输出 + +构建输出应支持: + +- 标准输出和标准错误的增量读取; +- 可点击的文件、行、列诊断; +- 编译错误、警告、退出码和取消状态; +- 结构化的成功、失败、取消和结果未知状态; +- 对应 LSP 项目模型的刷新或失效通知。 + +构建失败不能直接导致 LSP session 被销毁;构建和语言服务是同一插件内的独立模块。 + +### 8.4 测试和调试 + +测试模块负责发现 workspace、文件和测试用例,并返回结构化测试计划。调试模块负责提供 debug adapter、启动目标、源码映射和断点能力。它们必须复用插件声明的工具链和项目模型,不能重新猜测 SDK 路径。 + +## 9. 基础语言导航 + +### 9.1 MVP 能力 + +第一阶段至少实现: + +- 跳转到定义:`textDocument/definition`; +- 跳转到声明:`textDocument/declaration`; +- 跳转到类型定义:`textDocument/typeDefinition`; +- 跳转到实现:`textDocument/implementation`; +- 查找引用:`textDocument/references`; +- 当前文件符号和大纲:`textDocument/documentSymbol`; +- 悬停信息:`textDocument/hover`。 + +实际可用性以服务器 initialize 返回的 capability 为准,不能仅根据插件 manifest 假设服务器支持某项功能。 + +### 9.2 导航交互 + +- 只有一个结果时直接打开目标位置; +- 多个结果时显示排序稳定的结果列表; +- 同一文件结果保持相对位置排序; +- 外部依赖以只读或虚拟文档打开; +- 记录导航历史,支持返回原位置; +- 目标文件不存在、资源被删除或 provider 已停止时显示可操作错误; +- 新请求开始后丢弃旧请求结果; +- 工作区切换后禁止旧工作区结果写入当前编辑器。 + +### 9.3 文档同步和坐标 + +导航请求必须在发送前冲刷编辑器待同步变更,使用 LSP 的零基行号和 UTF-16 列。跨产品显示给用户的行号仍使用一基行号。文件路径使用工作区相对路径作为稳定标识,绝对路径只留在平台边界和 `file://` URI 中。 + +### 9.4 降级策略 + +LSP 暂不可用时,内置轻量 provider 可以提供当前文件符号、简单大纲或有限的同文件导航。降级结果必须标记来源,不能伪装成完整跨项目语义结果。宿主不得在每种语言中复制一个新的语义分析器。 + +## 10. 后续语言智能能力 + +在导航 MVP 稳定后,按以下顺序扩展: + +1. 诊断和错误列表; +2. 补全、触发字符和签名帮助; +3. 代码操作和快速修复; +4. 文档/选区格式化; +5. 重命名; +6. 语义高亮和内嵌提示; +7. Code Lens、工作区符号和调用层级; +8. 测试、运行和调试入口。 + +每项能力都必须经过 capability 检查、取消、超时、旧结果保护和明确错误投影。LSP 不支持时,UI 应隐藏不可用操作或显示“当前服务器不支持此功能”,不能显示按钮后静默失败。 + +## 11. 项目识别和依赖模型 + +语言插件需要声明项目文件、依赖管理文件、源码根和依赖目录。例如: + +| 语言 | 项目/依赖输入示例 | +| --- | --- | +| Go | `go.mod`、`go.work`、`go.sum`、`vendor` | +| PHP | `composer.json`、`vendor` | +| JavaScript/TypeScript | `package.json`、锁文件、`node_modules` | +| Rust | `Cargo.toml`、`Cargo.lock`、workspace members | + +项目模型由语言插件或其上游工具提供。宿主只负责生命周期、缓存失效、结果排序和 UI 展示,不根据运行配置自行猜测依赖树。 + +打开项目时允许进行一次完整解析;管理文件变化时执行有界的增量刷新;普通编辑不应重复扫描整个依赖仓库。插件休眠时可以复用已验证的快照,但工作区、插件版本或输入摘要变化后必须失效。 + +## 12. 安全、分发和存储边界 + +### 12.1 下载安全 + +- 所有官方下载源必须使用 HTTPS; +- 归档、入口和许可证路径必须由 manifest 固定; +- 下载完成后必须验证 SHA-256 或签名; +- 不接受项目文件提供的任意下载 URL、安装命令或 shell 参数; +- 安装前检查归档根目录、路径穿越和符号链接; +- 下载临时文件失败或取消后必须清理。 + +### 12.2 发布包只读 + +构建脚本可以写入待签名的插件 bundle。已安装的 macOS app bundle、插件 bundle 和 Windows 安装目录在运行时视为只读。运行时日志、缓存、索引、插件状态、下载物、解压物、锁和 LSP workspace 状态必须通过平台存储适配器放到用户级 Application Support、Caches、临时目录或工作区。 + +### 12.3 资源所有权 + +每个资源都要能回答: + +- 谁下载或创建它; +- 谁拥有它; +- 它的版本和校验如何确认; +- 它存放在哪个可写目录; +- 插件禁用、升级、回滚和卸载时如何处理; +- 是否影响代码签名、安装包基线或 Sparkle differential update。 + +## 13. 错误和可观测性 + +跨 Rust、Swift、TypeScript、Tauri 和进程边界的错误至少包含: + +- 稳定 `code`; +- 用户可读 `message`; +- `stage`,例如 discovery、download、verify、launch、initialize、request、shutdown; +- 可选平台 `details`; +- 可选退出码和操作 ID。 + +建议的稳定错误类别包括: + +`not_configured`、`path_missing`、`version_incompatible`、`download_failed`、`checksum_mismatch`、`archive_invalid`、`permission_denied`、`launch_failed`、`initialize_timeout`、`server_exited`、`capability_unavailable`、`operation_cancelled`、`requires_restart`。 + +诊断日志需要记录插件 ID、语言 ID、工具 ID、版本、阶段、耗时和结果,但不得记录 token、密码、完整环境变量或未经脱敏的用户目录。用户应能从插件详情页打开或导出相关诊断信息。 + +## 14. 跨平台实现边界 + +| 层 | macOS | Windows | 共享要求 | +| --- | --- | --- | --- | +| UI | SwiftUI/AppKit 插件管理和语言面板 | React/Tauri 插件管理和语言面板 | 状态、能力和错误语义一致 | +| 应用层 | Feature Model、Coordinator、Service | React feature、host adapter | 不复制语言语义 | +| Core | Rust Core bridge | Rust Core/Tauri bridge | LSP session、DTO、取消、排序、稳定错误 | +| 平台适配 | Bundle、签名、文件选择、进程、Application Support | 安装目录、签名、文件选择、进程、AppData | 安装目录只读 | +| 插件 | `Plugins/mac/` 原生包 | `Plugins/win/` Tauri/Windows 包 | manifest 能力语义一致,源码实现独立 | + +新增用户可见能力时,需要同步更新 `shared/platform-feature-matrix.json` 及生成视图。平台实现存在但未完成实机验证时,保持 `verificationStatus: pending`,不能把代码存在当作已验收。 + +## 15. 分阶段交付 + +### 15.1 完整迁移的实施阶段 + +下面的阶段是实现顺序,不代表最终只完成部分迁移。每一阶段都要减少旧契约的拥有范围,不能新增依赖让双重真源继续扩大。 + +#### M0:契约盘点和分类 + +- 盘点 `LitheModuleAPI`、`LitheCoreContracts`、现有 Rust JSON/C ABI 和 Windows TypeScript 类型; +- 把类型分为 Rust-owned wire contract、平台 adapter contract 和 UI-only state; +- 为每个现有类型记录唯一目标、消费者、兼容字段和删除条件; +- 冻结新的语言专属类型进入宿主模块。 + +#### M1:Rust 契约真源 + +- 在 Rust Core 中建立稳定的 contracts/models 模块; +- 为 ID、版本、manifest、toolchain、LSP launch、能力、生命周期、错误和任务计划增加 `serde` 类型; +- 为每一类契约补充共享 fixture、确定性排序和版本字段; +- 通过现有 JSON command/C ABI 暴露读取、校验和事件接口。 + +#### M2:生成 bindings 和兼容桥 + +- 从 Rust 契约或共享 schema 生成 Swift/TypeScript bindings; +- 将现有手写 DTO 改为 bindings 的别名或转换器; +- 禁止在 Swift/TypeScript 中增加新的字段语义、状态分支或错误分类; +- 为每个兼容桥记录迁移调用点和删除条件。 + +#### M3:插件彻底脱离旧宿主实现 + +- 先完成 GoSupport,再迁移 PHP 和后续官方语言插件; +- 清除 `OfficialPluginCatalog` 中的语言专属静态 manifest; +- 让插件从自身 `Plugins//Official//` 的 manifest 获得语言和模块描述; +- 删除宿主中的语言专属 capability、工具链路径和 fallback; +- 确认宿主 target 不链接插件实现,插件只依赖生成 bindings 和 host service。 + +#### M4:Rust 接管运行时语义 + +- Rust 接管 LSP、文档、导航、诊断、构建计划、取消、资源和模块生命周期的跨平台状态; +- macOS 和 Windows 只保留路径、进程、Bundle/Worker、存储、签名和 UI 适配; +- 所有平台消费同一组 Rust events、operation IDs 和稳定错误; +- 删除 Swift/TypeScript 中重复的生命周期状态机和协议解析。 + +#### M5:删除旧契约和完成验收 + +- 删除旧手写 DTO、旧语言 catalog、旧语言 fallback 和仅为兼容保留的实现; +- 删除没有调用者的兼容桥和临时转换器; +- 完成 macOS、Windows、Rust Core、插件分发和运行时不可变性验证; +- 更新平台功能矩阵、共享契约和迁移文档。 + +### 15.2 完整迁移的完成定义 + +只有同时满足以下条件,才算完成“公共契约下沉 Rust”的目标: + +- Rust Core 是所有跨平台契约的唯一可执行语义真源; +- Swift 和 TypeScript 只使用生成 bindings、公共 host service 和平台适配器; +- GoSupport、PHP Support 及后续官方语言插件全部位于平台对应的 `Plugins` 目录; +- 宿主不包含任何语言专属的第二份 manifest、capability、工具链路径或 fallback; +- 插件删除后,宿主不会通过旧代码恢复同名语言能力; +- LSP、构建、测试、调试和导航的状态、错误、取消和结果在两端使用同一 wire contract; +- 旧 `LitheModuleAPI`/`LitheCoreContracts` 中仅用于语言契约的重复定义已经删除; +- 运行时 bundle 不可变、插件签名、进程清理和跨平台功能矩阵验证全部通过。 + +### 15.3 本次实现切片:macOS PHP + +本次改动只把 PHP Support 作为 macOS 的迁移样板,不把“已经完成 PHP +样板”误写成“所有语言已经完成迁移”。已落地的边界如下: + +- `Plugins/mac/Official/PhpSupport/` 是 PHP manifest、LSP 清单、模块源码和 + 测试的唯一源码归属;PHP 模块不再导入 `OfficialPluginCatalog` 或 + `BuiltInPluginCatalog`。 +- Rust Core 提供 `plugin.validateManifest`、`plugin.validateLanguageServer` + 和 `plugin.lifecycle` 三个命令。macOS 插件包加载前调用这些命令,Swift + 只负责文件读取、Bundle 入口和本机可执行文件检查。 +- PHP 的 `language-server.json` 固定 Intelephense 版本、HTTPS 归档、SHA-256、 + `bin/intelephense` 启动器和 `--stdio` 参数;下载、解压、签名和缓存仍由 + 构建或平台适配器负责。 +- `RealPhpIntegrationTests` 在设置 `LITHE_RUN_PHP_INTEGRATION=1` 且提供真实 + Intelephense 时,先注册 `PhpLanguageServerModule`,再验证 initialize/ready、 + 补全、Hover、定义、引用、诊断、关闭文档;随后通过模块禁用停止进程、重新 + 启用插件并重新注册 capability,最后再次停止并检查模块资源已清空。缺少 + PHP/Composer/PHPUnit 的机器会跳过 PHPUnit 外部工具用例,并在验收记录中标明 + 未验证项。这个真实用例验证的是 macOS 模块与 LSP session 的绑定;Rust + `plugin.lifecycle` reducer 的跨平台状态提交仍需在后续 package store/session + registry 迁移中接入,不能把本次 PHP 用例当作全平台迁移完成。 +- Go、Windows 插件包和其余语言仍按 M2–M5 继续迁移;它们必须消费同一 Rust + Core wire contract,不能复制 PHP 的宿主路径或重新引入旧 catalog。 + +### P0:工具链和生命周期基础 + +- 插件详情页显示 SDK、LSP 和构建能力; +- SDK/LSP 自动检测、路径选择和版本健康检查; +- 插件托管下载、进度、取消、校验和原子安装; +- LSP 与插件强绑定; +- 禁用、重启、更新、回滚和卸载的进程清理; +- 卸载时选择是否保留可独立管理的 SDK/LSP; +- Go 或一个代表性非 Rust 语言完成端到端接入。 + +### P1:基础语言使用体验 + +- 定义、声明、类型定义、实现和引用跳转; +- 文档符号、悬停、诊断和错误导航; +- 构建任务、输出诊断和点击定位; +- 未保存缓冲区同步、取消和旧结果保护; +- 项目文件识别和最小依赖模型。 + +### P2:完整开发工作流 + +- 补全、签名帮助、格式化、重命名和代码操作; +- 测试发现与执行; +- Debug Adapter、断点和源码映射; +- 多版本工具链切换、回滚和项目锁定; +- 多根工作区和更完整的依赖浏览器。 + +## 16. 验收标准 + +### 16.1 工具链 + +- 干净环境中没有 SDK 时,插件页面能解释缺失原因并提供下载; +- 选择外部 SDK 后,版本检查和 LSP 启动使用该 SDK; +- 下载过程中取消不会留下半成品或后台进程; +- 篡改归档后校验失败,旧版本仍可用; +- 不同架构或不兼容版本有明确错误; +- LSP 启动失败不会伪装为“已就绪”。 + +### 16.2 生命周期 + +- 禁用插件会停止所有归属进程、session、watcher 和 timer; +- 插件卸载默认清理托管资源; +- 选择保留后资源显示为“已保留未绑定”,卸载后不会启动; +- 重新安装同版本插件可以重新绑定通过校验的保留资源; +- 更新和回滚不会引用已被删除或替换的旧入口; +- 应用退出后没有 Lithe、LSP、构建或测试残留进程。 + +### 16.3 导航和构建 + +- 未保存文件中的定义和引用请求使用最新文本; +- 多结果顺序稳定,旧请求结果不会覆盖新请求; +- 工作区切换后旧 provider 的结果被丢弃; +- 构建错误能定位到文件、行和列; +- 插件禁用后不会通过宿主的另一个通用进程继续提供该语言能力。 + +### 16.4 安装目录完整性 + +典型启动、下载、LSP 工作流、构建、插件禁用和卸载前后,已安装 bundle 或 Windows 安装目录的文件清单和哈希不发生运行时变化。必须运行 `scripts/verify-runtime-bundle-immutability.sh`,并覆盖两端资源解析路径。 + +### 16.5 插件目录和零旧实现依赖 + +- Go Support 的源码、manifest、工具链描述、插件入口和测试全部位于 `Plugins/mac/Official/GoSupport/`; +- 其他官方语言插件分别位于 `Plugins/mac/Official//` 或 `Plugins/win/Official//`; +- 宿主产品 target 不链接语言插件 target;插件只依赖公共 API、公共契约和注入的 host service; +- 宿主静态目录、AppModel、旧语言模块和平台 fallback 不包含该语言的第二份实现; +- 工具链额外入口(例如用户级 bin、PATH 派生目录)必须写在插件自己的 `toolchain.json.discovery`,宿主只提供通用占位符展开和可执行文件筛选; +- 从文件目录、Swift/TypeScript import、SwiftPM target 依赖和运行时注册路径均能证明插件与旧语言实现没有反向依赖; +- 删除或隔离插件目录后,宿主只能报告能力不可用,不能通过旧代码继续提供同名语言能力。 + +## 17. 验证与测试要求 + +实现时至少补充以下测试层次: + +1. manifest 和 provider schema 的确定性解析测试; +2. 工具路径优先级、版本约束和架构过滤测试; +3. 下载取消、校验失败、归档路径穿越和原子安装测试; +4. 插件启停、LSP session 关闭、崩溃重启和旧结果隔离测试; +5. 定义、引用、未保存文本和多结果导航 fixture; +6. 构建计划、诊断解析、取消和重试测试; +7. macOS 与 Windows 共享契约和平台边界测试; +8. bundle/安装目录不可变验证; +9. 插件目录归属、宿主 target 依赖和旧实现 import 扫描; +10. 平台功能矩阵和生成视图验证。 + +Rust Core、共享契约、macOS 或 Windows 实现发生变化时,运行对应的仓库验证脚本;不能只通过 UI 试运行代替 Core、插件分发和资源边界验证。 + +## 18. 待确认问题 + +以下问题在具体实现前需要形成明确选择: + +1. 首批正式支持的非 Rust 语言是 Go、PHP,还是先只选择一种作为完整样板? +2. 官方 LSP 默认采用构建时打包,还是安装后由插件托管下载?两者是否同时支持? +3. “保留 LSP”是否只适用于用户级下载资源,内置签名资源是否必须迁移后才能保留? +4. 工具链版本是允许自动升级,还是必须由用户确认后切换? +5. 项目级工具链配置是否需要提交到仓库,还是只保留在本机 `.lithe` 配置? +6. 第一阶段是否把格式化、测试和调试纳入同一个语言插件,还是按 P2 延后? + +## 19. 参考资料 + +- [语言工具分层与 LSP Runtime 归属](../../.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md) +- [模块运行时边界与生命周期](../../.agents/notes/implemented/architecture/2026-09-13-module-runtime-boundaries-and-lifecycle.md) +- [PHP Support 插件所有权与执行计划](../../.agents/notes/implemented/architecture/2026-09-18-php-support-plugin-ownership-and-plans.md) +- [LSP 插件构建与语言服务器资源归属](../../.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md) +- [工作区依赖浏览器与语言插件 Provider 边界](../../.agents/notes/implemented/architecture/2026-09-18-workspace-dependency-browser.md) +- [语言 Provider Schema](../reference/language-providers.schema.json) +- [Application Boundary Contract](../../shared/contracts/application-boundary.md) +- [Rust Core API Contract](../../shared/contracts/rust-core-api.md) diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index a632b3292..63865870f 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -106,7 +106,7 @@ wsl-boundaries,Windows 专属,WSL,跨 WSL 边界移动与重命名,平台专属, editor-file-encoding-reopen,编辑器,文本编辑,按指定编码重新打开文本文件,已实现,待验证,已实现,待验证,Editor,在 macOS 和 Windows 实机验证 UTF-8、UTF-8 BOM 与 GBK/GB18030 的自动识别,并分别验证 Shift JIS、Windows-1252 的指定编码重新打开、编码转换保存、脏文件选择和外部修改保护。,,macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift; macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift; macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift; macos/Sources/Lithe/Platform/MacOS/FileSystem/MacDocumentEncoding.swift,windows/tauri/src/features/command-palette/components/encoding-picker.tsx; windows/tauri/src/features/editor/services/document-encoding-workflow.ts; windows/tauri/crates/project/src/document_file.rs editor-file-encoding-save,编辑器,文本编辑,按指定编码保存文本文件,已实现,待验证,已实现,待验证,Editor,在 macOS 和 Windows 实机验证 UTF-8、UTF-8 BOM 与 GBK/GB18030 的自动识别,并分别验证 Shift JIS、Windows-1252 的指定编码重新打开、编码转换保存、脏文件选择和外部修改保护。,,macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift; macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift; macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift; macos/Sources/Lithe/Platform/MacOS/FileSystem/MacDocumentEncoding.swift,windows/tauri/src/features/command-palette/components/encoding-picker.tsx; windows/tauri/src/features/editor/stores/editor-app.store.ts; windows/tauri/crates/project/src/document_file.rs php-optional-plugin,语言支持,PHP,PHP 按需安装与插件生命周期,已实现,待验证,已实现,待验证,PHP Support,macOS 在干净安装上打开插件管理,确认可从当前发行渠道下载并安装 PHP、失败时可从磁盘导入;安装后重启,在 LSP 界面只控制当前项目开关和状态,验证重装、卸载均在重启后生效且用户工具保留。Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。,,Plugins/mac/Official/PhpSupport; scripts/official-plugin-distribution.mjs; 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,Plugins/win/Official/PhpSupport; windows/tauri/src/extensions/registry/extension-store-lifecycle.ts; windows/tauri/src-tauri/src/language_tools.rs; windows/tauri/src/extensions/packages/local-extension-package.ts; scripts/verify-windows-plugin-isolation.mjs -php-run-test,语言支持,PHP,PHP 插件运行与 PHPUnit 测试,已实现,待验证,部分实现,待验证,PHP Support,macOS 运行 PHP 文件和单条/整套 PHPUnit;Windows 运行字符串/数组 Composer script 和整套 PHPUnit。禁用后菜单消失、运行进程退出,特殊文件名保持原样。Windows 暂不支持单方法发现。,,Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Capabilities/PhpExecutionCapability.swift; macos/Tests/LitheTests/RealPhpIntegrationTests.swift,Plugins/win/Official/PhpSupport/plugin.ts; windows/tauri/src/extensions/run; windows/tauri/src/extensions/ui/services/ui-extension-worker-runtime.test.ts +php-run-test,语言支持,PHP,PHP 插件运行与 PHPUnit 测试,已实现,已验证,部分实现,待验证,PHP Support,macOS 运行 PHP 文件和单条/整套 PHPUnit;Windows 运行字符串/数组 Composer script 和整套 PHPUnit。禁用后菜单消失、运行进程退出,特殊文件名保持原样。Windows 暂不支持单方法发现。,,Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Capabilities/PhpExecutionCapability.swift; macos/Tests/LitheTests/RealPhpIntegrationTests.swift,Plugins/win/Official/PhpSupport/plugin.ts; windows/tauri/src/extensions/run; windows/tauri/src/extensions/ui/services/ui-extension-worker-runtime.test.ts macos-php-plugin-publisher-signature,发布与安装,官方插件,独立 PHP 插件包使用 Lithe publisher 签名并可发布到 GitHub Release,已实现,待验证,平台专属,不适用,Release,macOS 构建配置 LITHE_PLUGIN_PACKAGE_PRIVATE_KEY,在 arm64 和 x86_64 生成 PHP zip;解压后验证签名文档覆盖完整文件树,篡改任意文件、manifest、版本或签名后确认插件安装被拒绝,并从 GitHub Release 下载包完成一次安装。Windows 使用独立本地插件包流程,本能力不适用。,,macos/Sources/LithePluginPackageSigning/PluginPackageSignature.swift; macos/Tools/LithePluginPackageSigner/main.swift; macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift; scripts/build-official-plugins.sh; .github/workflows/release-macos.yml; .github/workflows/release-preview-macos.yml; Plugins/mac/Official/PhpSupport/plugin.json,Plugins/win/Official/PhpSupport/plugin.ts; windows/tauri/src/extensions/packages/local-extension-package.ts git-workspace-staged-commit,版本控制,Git,按文件所属仓库批量提交及推送,保留每仓库结果,已实现,待验证,已实现,待验证,Git,勾选两个独立仓库文件,一次提交并推送,验证各自 HEAD 和远程;停止一个操作后其余独立仓库继续,已成功仓库不会回滚。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift; macos/Sources/Lithe/Views/Git/CommitAreaView.swift; macos/Tests/LitheGitModuleTests/GitModuleTests.swift; rust/lithe-core/src/git/commit_state.rs; rust/lithe-core/src/tests/git_workspace_commit.rs; rust/lithe-core/src/git/workspace_commit.rs; rust/lithe-core/src/git/workspace_commit/tests.rs; shared/fixtures/git/workspace-commit-workflow-v1.json,windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/git/components/git-commit-panel.tsx; windows/tauri/src/features/git/components/git-workspace-commit-review.tsx; windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts; windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts; windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx; windows/tauri/src-tauri/src/platform.rs; shared/fixtures/git/workspace-commit-workflow-v1.json; windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx; windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx git-submodule-commit-plan,版本控制,Git,子模块先提交与推送、自动更新父引用、计划变化再次确认,已实现,待验证,已实现,待验证,Git,只勾选孙仓库文件,确认计划自动列出父祖仓库的引用更新且可关闭;确认期间改变暂存内容或分支,必须显示新计划再次确认。父仓库未暂存文件不能被带入;只勾选父引用时先推送子仓库现有提交,Git 发布检查应拒绝未发布的子引用。子仓库元数据被外部删除后必须拒绝读取和写入,不能向上回退父仓库。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift; macos/Sources/Lithe/Views/Git/CommitAreaView.swift; macos/Tests/LitheGitModuleTests/GitModuleTests.swift; rust/lithe-core/src/git/commit_state.rs; rust/lithe-core/src/tests/git_workspace_commit.rs; rust/lithe-core/src/git/workspace_commit.rs; rust/lithe-core/src/git/workspace_commit/tests.rs; shared/fixtures/git/workspace-commit-workflow-v1.json,windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/git/components/git-commit-panel.tsx; windows/tauri/src/features/git/components/git-workspace-commit-review.tsx; windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts; windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts; windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx; windows/tauri/src-tauri/src/platform.rs; shared/fixtures/git/workspace-commit-workflow-v1.json; windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx; windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx @@ -115,3 +115,4 @@ editor-unicode-text-classification,编辑器,文本编辑,中文 properties 与 ide-api-plugins,插件与扩展,IDE API / MCP,插件调用已授权项目的环境、Maven 与运行 API,已实现,待验证,已实现,待验证,IDE API,双端插件枚举显式授权的项目,读取环境并调用已允许的操作;撤销项目权限后拒绝调用,Windows 未声明 permissions.ide 的 Worker 无法访问。,复用 MCP 的同一应用功能和参数校验,不扩张语言包权限;原生运行验收待完成。,macos/Sources/LitheModuleAPI/Plugins/IDECapabilities.swift; macos/Sources/Lithe/Application/Composition/IdeCapabilityRegistry.swift,Plugins/win/SDK/ide-capabilities.ts; windows/tauri/src/extensions/ui/services/ide-extension-host.ts; windows/tauri/src/extensions/ui/services/ide-extension-host.test.ts java-service-devtools-update,Run/Debug,Java 服务更新,运行中的 Spring Boot DevTools 服务手动保存并编译更新,已实现,待验证,已实现,待验证,Run/Debug,在双端运行 Spring Boot HTTP 服务,修改方法体并更新,检查响应、PID、DevTools 日志与断点暂停状态;覆盖编译失败、JVM 拒绝结构修改、连续点击、停止/重启、依赖路径变化。对比运行前后安装目录清单/哈希。Linux 仅完成共享 Core 和前端测试,原生验证待完成。,,macos/Sources/Lithe/Views/Run/RunView.swift,windows/tauri/src/features/run/components/run-pane.tsx java-service-hot-code-replace,Run/Debug,Java 服务更新,本地 Java Debug 服务手动编译并热替换代码,已实现,待验证,已实现,待验证,Run/Debug,在双端运行 Spring Boot HTTP 服务,修改方法体并更新,检查响应、PID、DevTools 日志与断点暂停状态;覆盖编译失败、JVM 拒绝结构修改、连续点击、停止/重启、依赖路径变化。对比运行前后安装目录清单/哈希。Linux 仅完成共享 Core 和前端测试,原生验证待完成。,,macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift,windows/tauri/src/features/debugger/components/java-service-update.tsx +rust-core-plugin-contract,扩展,插件与语言服务,Rust Core 校验插件清单、语言服务器清单并约束插件资源生命周期,已实现,已验证,部分实现,待验证,Core / Extensions,macOS 使用 PHP 插件清单和真实 Intelephense 完成清单校验、initialize、补全、Hover、定义、引用、诊断、关闭、停止、重启和进程清理;Windows 迁移插件包后使用同一 Core 命令和生命周期 fixture 完成对应验证。,,rust/lithe-core/src/plugins/mod.rs; rust/lithe-core/src/protocol/command.rs; rust/lithe-core/src/runtime/dispatcher.rs; macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerPackageValidator.swift; macos/Tests/LitheTests/RealPhpIntegrationTests.swift; shared/contracts/rust-core-api.md,rust/lithe-core/src/plugins/mod.rs; windows/tauri/src/extensions diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index cfd7437e3..e12501249 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -4,9 +4,9 @@ - 最后复核:2026-10-01 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:116 -- macOS:实现:✅ 102 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 4 平台专属;验证:✔️ 0 已验证,🔍 105 待验证,— 11 不适用 -- Windows:实现:✅ 98 已实现,🟡 11 部分实现,❌ 5 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 109 待验证,— 7 不适用 +- 功能项:117 +- macOS:实现:✅ 103 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 4 平台专属;验证:✔️ 2 已验证,🔍 104 待验证,— 11 不适用 +- Windows:实现:✅ 98 已实现,🟡 12 部分实现,❌ 5 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 110 待验证,— 7 不适用 ## 实现状态定义 @@ -228,11 +228,12 @@
-扩展 · 1 个能力点 +扩展 · 2 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | | 插件与主题 | **插件、语言服务与主题管理**
plugin-extension-management | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/PluginManagementView.swift`、`macos/Sources/Lithe/Views/App/SettingsView.swift`、`macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift`、`macos/Tests/LitheTests/PluginManagementPresentationTests.swift`、`macos/Sources/Lithe/Platform/MacOS/Plugins` | ✅ 已实现
🔍 待验证
`windows/tauri/src/extensions`、`windows/tauri/src/features/settings`、`windows/tauri/src-tauri/src/language_tools.rs`、`windows/tauri/src/extensions/registry/extension-store-lifecycle.test.ts` | Extensions | macOS 全新安装时打开插件页,确认显示 PHP Support 未安装占位项,并可选择已签名的 PHP 插件包安装;选择非 PHP 包应被拒绝,安装后可搜索并启用、禁用 PHP,确认状态与重启恢复,且页面不显示无恢复路径的卸载操作;同时打开 Maven 面板确认插件内容不被遮挡。Windows 检查扩展管理入口和持久化。 Windows 缺少 Bun 或语言服务器时更新扩展,确认旧扩展的安装、启用和待更新状态保留,错误给出安装依赖后重试的提示;预检期间禁用或卸载扩展,确认返回后不会继续安装或重新启用;手动将 Marksman 等语言服务器安装到 PATH 后重启,确认能识别并启用。 | | +| 插件与语言服务 | **Rust Core 校验插件清单、语言服务器清单并约束插件资源生命周期**
rust-core-plugin-contract | ✅ 已实现
✔️ 已验证
`rust/lithe-core/src/plugins/mod.rs`、`rust/lithe-core/src/protocol/command.rs`、`rust/lithe-core/src/runtime/dispatcher.rs`、`macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerPackageValidator.swift`、`macos/Tests/LitheTests/RealPhpIntegrationTests.swift`、`shared/contracts/rust-core-api.md` | 🟡 部分实现
🔍 待验证
`rust/lithe-core/src/plugins/mod.rs`、`windows/tauri/src/extensions` | Core / Extensions | macOS 使用 PHP 插件清单和真实 Intelephense 完成清单校验、initialize、补全、Hover、定义、引用、诊断、关闭、停止、重启和进程清理;Windows 迁移插件包后使用同一 Core 命令和生命周期 fixture 完成对应验证。 | |
@@ -281,7 +282,7 @@ | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | | PHP | **PHP 按需安装与插件生命周期**
php-optional-plugin | ✅ 已实现
🔍 待验证
`Plugins/mac/Official/PhpSupport`、`scripts/official-plugin-distribution.mjs`、`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` | ✅ 已实现
🔍 待验证
`Plugins/win/Official/PhpSupport`、`windows/tauri/src/extensions/registry/extension-store-lifecycle.ts`、`windows/tauri/src-tauri/src/language_tools.rs`、`windows/tauri/src/extensions/packages/local-extension-package.ts`、`scripts/verify-windows-plugin-isolation.mjs` | PHP Support | macOS 在干净安装上打开插件管理,确认可从当前发行渠道下载并安装 PHP、失败时可从磁盘导入;安装后重启,在 LSP 界面只控制当前项目开关和状态,验证重装、卸载均在重启后生效且用户工具保留。Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。 | | -| PHP | **PHP 插件运行与 PHPUnit 测试**
php-run-test | ✅ 已实现
🔍 待验证
`Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Capabilities/PhpExecutionCapability.swift`、`macos/Tests/LitheTests/RealPhpIntegrationTests.swift` | 🟡 部分实现
🔍 待验证
`Plugins/win/Official/PhpSupport/plugin.ts`、`windows/tauri/src/extensions/run`、`windows/tauri/src/extensions/ui/services/ui-extension-worker-runtime.test.ts` | PHP Support | macOS 运行 PHP 文件和单条/整套 PHPUnit;Windows 运行字符串/数组 Composer script 和整套 PHPUnit。禁用后菜单消失、运行进程退出,特殊文件名保持原样。Windows 暂不支持单方法发现。 | | +| PHP | **PHP 插件运行与 PHPUnit 测试**
php-run-test | ✅ 已实现
✔️ 已验证
`Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule/Capabilities/PhpExecutionCapability.swift`、`macos/Tests/LitheTests/RealPhpIntegrationTests.swift` | 🟡 部分实现
🔍 待验证
`Plugins/win/Official/PhpSupport/plugin.ts`、`windows/tauri/src/extensions/run`、`windows/tauri/src/extensions/ui/services/ui-extension-worker-runtime.test.ts` | PHP Support | macOS 运行 PHP 文件和单条/整套 PHPUnit;Windows 运行字符串/数组 Composer script 和整套 PHPUnit。禁用后菜单消失、运行进程退出,特殊文件名保持原样。Windows 暂不支持单方法发现。 | | diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerPackageValidator.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerPackageValidator.swift index b4708701d..6f6453759 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerPackageValidator.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerPackageValidator.swift @@ -1,17 +1,54 @@ import Foundation +import LitheCoreContracts import LitheModuleAPI -struct MacPluginLanguageServerManifest: Decodable { +struct MacPluginLanguageServerManifest: Codable { let schemaVersion: Int let pluginID: String + let languageID: String let toolID: String let version: String let archiveURL: URL let archiveSHA256: String let archiveFormat: String let archiveRoot: String + let launcherRelativePath: String + let arguments: [String] let entrypoint: String let license: String + + private enum CodingKeys: String, CodingKey { + case schemaVersion, pluginID, languageID, toolID, version, archiveURL + case archiveSHA256, archiveFormat, archiveRoot, launcherRelativePath + case arguments, entrypoint, license + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try values.decode(Int.self, forKey: .schemaVersion) + pluginID = try values.decode(String.self, forKey: .pluginID) + languageID = try values.decode(String.self, forKey: .languageID) + toolID = try values.decode(String.self, forKey: .toolID) + version = try values.decode(String.self, forKey: .version) + archiveURL = try values.decode(URL.self, forKey: .archiveURL) + archiveSHA256 = try values.decode(String.self, forKey: .archiveSHA256) + archiveFormat = try values.decode(String.self, forKey: .archiveFormat) + archiveRoot = try values.decode(String.self, forKey: .archiveRoot) + launcherRelativePath = try values.decode(String.self, forKey: .launcherRelativePath) + arguments = try values.decode([String].self, forKey: .arguments) + entrypoint = try values.decode(String.self, forKey: .entrypoint) + license = try values.decode(String.self, forKey: .license) + } +} + +private struct PluginManifestValidationRequest: Encodable { + let hostVersion: String + let manifest: ToolingJSONValue +} + +private struct PluginLanguageServerValidationRequest: Encodable { + let pluginID: String + let manifest: ToolingJSONValue } enum MacPluginLanguageServerPackageValidationError: Error, Equatable, LocalizedError { @@ -26,7 +63,7 @@ enum MacPluginLanguageServerPackageValidationError: Error, Equatable, LocalizedE case .invalidManifest: "language-server.json contains invalid metadata." case .missingLauncher: - "The Intelephense launcher is missing." + "The plugin language-server launcher is missing." } } } @@ -35,49 +72,60 @@ enum MacPluginLanguageServerPackageValidator { static func validate( packageAt packageURL: URL, pluginManifest: PluginManifest, + hostVersion: PluginVersion = BuiltInPluginCatalog.hostVersion, fileManager: FileManager = .default ) throws { - guard pluginManifest.id == OfficialPluginCatalog.phpPluginID else { return } + guard pluginManifest.languageSupports?.contains(where: { + $0.languageServerModuleID != nil + }) == true else { return } let manifestURL = packageURL.appendingPathComponent("language-server.json") guard let data = try? Data(contentsOf: manifestURL) else { throw MacPluginLanguageServerPackageValidationError.missingManifest } + let core = RustCoreBridge() + guard core.isAvailable else { + throw MacPluginLanguageServerPackageValidationError.invalidManifest + } let languageServerManifest: MacPluginLanguageServerManifest do { - languageServerManifest = try JSONDecoder().decode( - MacPluginLanguageServerManifest.self, - from: data - ) + // Forward the original JSON. Decoding into Swift DTOs first would + // silently normalize or drop fields before Core sees the package. + let decoder = JSONDecoder() + let rawManifest = try decoder.decode(ToolingJSONValue.self, from: Data( + contentsOf: packageURL.appendingPathComponent("plugin.json") + )) + let rawLanguageServer = try decoder.decode(ToolingJSONValue.self, from: data) + let _: ToolingJSONValue = try core.executeResult( + command: "plugin.validateManifest", + payload: PluginManifestValidationRequest( + hostVersion: hostVersion.description, + manifest: rawManifest + ) + ).get() + languageServerManifest = try core.executeResult( + command: "plugin.validateLanguageServer", + payload: PluginLanguageServerValidationRequest( + pluginID: pluginManifest.id.rawValue, + manifest: rawLanguageServer + ) + ).get() } catch { throw MacPluginLanguageServerPackageValidationError.invalidManifest } - guard languageServerManifest.schemaVersion == 1, - languageServerManifest.pluginID == pluginManifest.id.rawValue, - languageServerManifest.toolID == "intelephense", - !languageServerManifest.version.isEmpty, - languageServerManifest.archiveURL.scheme?.lowercased() == "https", - languageServerManifest.archiveURL.host != nil, - languageServerManifest.archiveFormat == "tarGzip", - isSafeRelativePath(languageServerManifest.archiveRoot), - isSafeRelativePath(languageServerManifest.entrypoint), - isSafeRelativePath(languageServerManifest.license), - languageServerManifest.archiveSHA256.count == 64, - languageServerManifest.archiveSHA256.allSatisfy({ $0.isHexDigit }), + guard languageServerManifest.pluginID == pluginManifest.id.rawValue, + pluginManifest.languageSupports?.contains(where: { $0.id == languageServerManifest.languageID }) == true, case .nativeBundle = pluginManifest.entrypoint.kind, let bundlePath = pluginManifest.entrypoint.bundlePath else { throw MacPluginLanguageServerPackageValidationError.invalidManifest } let launcherURL = packageURL .appendingPathComponent(bundlePath, isDirectory: true) - .appendingPathComponent("Contents/Resources/LanguageServers/php/bin/intelephense") + .appendingPathComponent("Contents/Resources/LanguageServers", isDirectory: true) + .appendingPathComponent(languageServerManifest.languageID, isDirectory: true) + .appendingPathComponent(languageServerManifest.launcherRelativePath) guard fileManager.isExecutableFile(atPath: launcherURL.path) else { throw MacPluginLanguageServerPackageValidationError.missingLauncher } } - private static func isSafeRelativePath(_ value: String) -> Bool { - !value.isEmpty - && !value.hasPrefix("/") - && !value.split(separator: "/", omittingEmptySubsequences: false).contains("..") - } } diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageDownloader.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageDownloader.swift index 90bcc5291..d6c921157 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageDownloader.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageDownloader.swift @@ -125,7 +125,7 @@ final class MacPluginPackageDownloader: MacPluginPackageDownloading { func download( pluginID: PluginID, - hostVersion _: PluginVersion + hostVersion: PluginVersion ) async throws -> MacDownloadedPluginPackage { let archiveURL = try configuration.archiveURL(pluginID: pluginID) try Task.checkCancellation() @@ -157,7 +157,8 @@ final class MacPluginPackageDownloader: MacPluginPackageDownloading { ) try MacPluginLanguageServerPackageValidator.validate( packageAt: packageURL, - pluginManifest: manifest + pluginManifest: manifest, + hostVersion: hostVersion ) } catch let error as MacPluginLanguageServerPackageValidationError { throw MacPluginPackageDownloadError.invalidLanguageServerPackage( diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift index 040e6bf9c..b61f8d05d 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift @@ -188,6 +188,7 @@ final class MacPluginPackageStore { try MacPluginLanguageServerPackageValidator.validate( packageAt: packageURL, pluginManifest: manifest, + hostVersion: hostVersion, fileManager: fileManager ) let candidate = InstalledPluginPackage( @@ -249,6 +250,7 @@ final class MacPluginPackageStore { try MacPluginLanguageServerPackageValidator.validate( packageAt: packageURL, pluginManifest: manifest, + hostVersion: hostVersion, fileManager: fileManager ) installed.append(InstalledPluginPackage( @@ -307,6 +309,7 @@ final class MacPluginPackageStore { try MacPluginLanguageServerPackageValidator.validate( packageAt: stagedURL, pluginManifest: manifest, + hostVersion: hostVersion, fileManager: fileManager ) } catch { @@ -395,6 +398,7 @@ final class MacPluginPackageStore { try MacPluginLanguageServerPackageValidator.validate( packageAt: previousPackageURL, pluginManifest: manifest, + hostVersion: hostVersion, fileManager: fileManager ) let restored = PluginInstallationRecord( diff --git a/macos/Tests/LitheTests/PluginPackageStoreTests.swift b/macos/Tests/LitheTests/PluginPackageStoreTests.swift index 120b5d859..a30dd22fc 100644 --- a/macos/Tests/LitheTests/PluginPackageStoreTests.swift +++ b/macos/Tests/LitheTests/PluginPackageStoreTests.swift @@ -559,6 +559,38 @@ struct PluginPackageStoreTests { to: packageURL.appendingPathComponent("plugin.json"), options: .atomic ) + if let languageSupport, languageSupport.languageServerModuleID != nil { + let launcherRoot = packageURL + .appendingPathComponent("Feature.bundle/Contents/Resources/LanguageServers/\(languageSupport.id)/bin", isDirectory: true) + try FileManager.default.createDirectory(at: launcherRoot, withIntermediateDirectories: true) + let launcher = launcherRoot.appendingPathComponent("server") + #expect(FileManager.default.createFile(atPath: launcher.path, contents: Data())) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: launcher.path + ) + let languageServerManifest = """ + { + "schemaVersion": 1, + "pluginID": "\(pluginID.rawValue)", + "languageID": "\(languageSupport.id)", + "toolID": "fixture-server", + "version": "1.0.0", + "archiveURL": "https://example.com/fixture-server-1.0.0.tgz", + "archiveSHA256": "0000000000000000000000000000000000000000000000000000000000000000", + "archiveFormat": "tarGzip", + "archiveRoot": "package", + "launcherRelativePath": "bin/server", + "arguments": ["--stdio"], + "entrypoint": "bin/server.js", + "license": "LICENSE.txt" + } + """ + try Data(languageServerManifest.utf8).write( + to: packageURL.appendingPathComponent("language-server.json"), + options: .atomic + ) + } return packageURL } @@ -603,12 +635,15 @@ struct PluginPackageStoreTests { { "schemaVersion": 1, "pluginID": "dev.lithe.plugin.php-support", + "languageID": "php", "toolID": "intelephense", "version": "1.15.1", "archiveURL": "https://registry.npmjs.org/intelephense/-/intelephense-1.15.1.tgz", "archiveSHA256": "24a33fe3cd7a2382f44285e2ae553a7e1c898cbc208ee20cee0693497ee9ebe2", "archiveFormat": "tarGzip", "archiveRoot": "package", + "launcherRelativePath": "bin/intelephense", + "arguments": ["--stdio"], "entrypoint": "lib/intelephense.js", "license": "LICENSE.txt" } diff --git a/macos/Tests/LitheTests/RealPhpIntegrationTests.swift b/macos/Tests/LitheTests/RealPhpIntegrationTests.swift index aa30967ac..822972d51 100644 --- a/macos/Tests/LitheTests/RealPhpIntegrationTests.swift +++ b/macos/Tests/LitheTests/RealPhpIntegrationTests.swift @@ -23,6 +23,7 @@ // // -force_load is required: the test bundle also contains the C bridge's weak // fallback, so a normal link can succeed without the Rust archive loaded. +import CryptoKit import Foundation import LitheApplicationKernel import LitheCoreContracts @@ -40,7 +41,15 @@ struct RealPhpIntegrationTests { let environment = ProcessInfo.processInfo.environment guard environment["LITHE_RUN_PHP_INTEGRATION"] == "1" else { return } + let pluginPackageURL = environment["LITHE_PHP_PLUGIN_PACKAGE"].map { + URL(fileURLWithPath: $0, isDirectory: true).standardizedFileURL + } + let packagedLauncher = pluginPackageURL?.appendingPathComponent( + "PhpSupport.bundle/Contents/Resources/LanguageServers/php/bin/intelephense" + ) + let initialPackageSnapshot = try pluginPackageURL.map(Self.snapshotPackage) let intelephenseURL = URL(fileURLWithPath: environment["LITHE_INTELEPHENSE_PATH"] + ?? packagedLauncher?.path ?? FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".bun/bin/intelephense").path) #expect(FileManager.default.isExecutableFile(atPath: intelephenseURL.path)) @@ -78,7 +87,8 @@ struct RealPhpIntegrationTests { return strtoupper($name); } - $value = str + $value = greet("lithe"); + $broken = str """ try #"{"name":"example/api","require":{"php":"^8.2"}}"# .write(to: rootURL.appendingPathComponent("composer.json"), atomically: true, encoding: .utf8) @@ -105,13 +115,38 @@ struct RealPhpIntegrationTests { shutdownTimeout: 5, core: core ) + let moduleRuntime = ModuleRuntime() + let workspaceManifest = try #require(BuiltInModuleCatalog.manifest(for: .workspace)) + try moduleRuntime.register(ModuleFactory(manifest: workspaceManifest) { + RealPhpWorkspaceModule(manifest: workspaceManifest) + }) + try moduleRuntime.register(ModuleFactory(manifest: PhpLanguageServerModule.moduleManifest) { + PhpLanguageServerModule() + }) + try await moduleRuntime.setEnabled(true, for: PhpLanguageServerModule.moduleManifest.id) + + let provider = try #require( + try await moduleRuntime.activateCapability(.languageServerExtension("php")) + as? any LanguageServerExtensionProviding + ) + #expect(provider.configuration.arguments == ["--stdio"]) + let support = LanguageSupportDeclaration( + id: "php", + displayName: "PHP", + fileExtensions: ["php", "phtml"], + projectFileNames: ["composer.json"], + languageServerModuleID: PhpLanguageServerModule.moduleManifest.id + ) let runtime = RealPhpLanguageRuntime(descriptor: descriptor, session: session) + let runtimeFactory = RealPhpLanguageProviderRuntimeFactory(runtime: runtime) let manager = LanguageToolingSessionManager( catalog: LanguageProviderCatalog(descriptors: [descriptor]), - runtimes: [runtime], - builtinCore: core + runtimeFactory: runtimeFactory, + builtinCore: core, + extensionRequiredProviderIDs: ["php"] ) defer { manager.stopAll() } + #expect(manager.registerLanguageServerExtension(provider, support: support)) try manager.synchronizeLanguageServer( for: sourceURL, @@ -129,7 +164,7 @@ struct RealPhpIntegrationTests { manager, sourceURL: sourceURL, text: source, - position: LanguageServerPosition(line: 9, utf16Column: 12), + position: LanguageServerPosition(line: 10, utf16Column: 13), rootURL: rootURL ) #expect(!completionItems.isEmpty) @@ -139,16 +174,76 @@ struct RealPhpIntegrationTests { manager, sourceURL: sourceURL, text: source, - position: LanguageServerPosition(line: 6, utf16Column: 11), + position: LanguageServerPosition(line: 4, utf16Column: 11), rootURL: rootURL ) #expect(hover?.contents.isEmpty == false) + // Navigation is routed through the same Rust-owned session. The call + // site is unsaved text, so a successful definition proves didOpen and + // the current in-memory snapshot reached Intelephense. + let definitions = try await Self.navigation( + manager, + method: "textDocument/definition", + sourceURL: sourceURL, + text: source, + position: LanguageServerPosition(line: 9, utf16Column: 10), + rootURL: rootURL + ) + #expect(definitions.contains { $0.url.standardizedFileURL == sourceURL.standardizedFileURL }) + #expect(definitions.contains { $0.range.start.line == 4 }) + + let references = try await Self.navigation( + manager, + method: "textDocument/references", + sourceURL: sourceURL, + text: source, + position: LanguageServerPosition(line: 9, utf16Column: 10), + rootURL: rootURL + ) + #expect(references.contains { $0.url.standardizedFileURL == sourceURL.standardizedFileURL }) + + // The intentionally incomplete unsaved expression must be reported by + // the server and projected back through the Core event stream. + let diagnosticPublished = await awaitChange(on: manager, timeout: .seconds(60)) { + !(manager.diagnostics(for: "php")[sourceURL] ?? []).isEmpty + } + #expect(diagnosticPublished, "Intelephense did not publish PHP diagnostics") + manager.closeDocument(sourceURL) - manager.stopLanguageServer(providerID: "php") + try await moduleRuntime.setEnabled(false, for: PhpLanguageServerModule.moduleManifest.id) let terminated = await Self.awaitSessionTermination(session) #expect(terminated, "intelephense did not terminate its process after the session stopped") - #expect(manager.languageServerStates["php"] == .stopped) + #expect(manager.languageServerStates["php"] == LanguageServerSessionState.stopped) + #expect(try moduleRuntime.snapshot(for: PhpLanguageServerModule.moduleManifest.id).state == .disabled) + + // Re-enabling the plugin creates a new capability and re-registers the + // same package-owned runtime before the workspace session is restarted. + try await moduleRuntime.setEnabled(true, for: PhpLanguageServerModule.moduleManifest.id) + let reenabledProvider = try #require( + try await moduleRuntime.activateCapability(.languageServerExtension("php")) + as? any LanguageServerExtensionProviding + ) + #expect(manager.registerLanguageServerExtension(reenabledProvider, support: support)) + _ = try manager.startLanguageServer(providerID: "php", rootURL: rootURL) + let restartedReady = await awaitChange(on: manager, timeout: .seconds(60)) { + manager.languageServerStates["php"] == LanguageServerSessionState.ready + } + #expect(restartedReady, "PHP language server did not restart") + try await moduleRuntime.setEnabled(false, for: PhpLanguageServerModule.moduleManifest.id) + let restartedStopped = await awaitChange(on: manager, timeout: .seconds(30)) { + manager.languageServerStates["php"] == LanguageServerSessionState.stopped + } + #expect(restartedStopped, "PHP language server did not stop after restart") + #expect(try moduleRuntime.snapshot(for: PhpLanguageServerModule.moduleManifest.id).state == .disabled) + + if let pluginPackageURL, let initialPackageSnapshot { + let finalPackageSnapshot = try Self.snapshotPackage(at: pluginPackageURL) + #expect( + finalPackageSnapshot == initialPackageSnapshot, + "PHP plugin package changed during LSP activation, shutdown, and restart" + ) + } } @Test @@ -162,14 +257,10 @@ struct RealPhpIntegrationTests { isDirectory: true ) let fileManager = FileManager.default - #expect(fileManager.isExecutableFile(atPath: projectURL.appendingPathComponent("vendor/bin/phpunit").path)) guard fileManager.isExecutableFile(atPath: projectURL.appendingPathComponent("vendor/bin/phpunit").path) else { return } - let phpURL = try #require( - Self.executableOnPath("php", environment: environment), - "The real PHPUnit test needs `php` on PATH." - ) + guard let phpURL = Self.executableOnPath("php", environment: environment) else { return } let processRegistry = ManagedProcessRegistry() let executionHost = MacLanguageExecutionHost(processRegistry: processRegistry) @@ -308,6 +399,30 @@ struct RealPhpIntegrationTests { } } + private static func navigation( + _ manager: LanguageToolingSessionManager, + method: String, + sourceURL: URL, + text: String, + position: LanguageServerPosition, + rootURL: URL + ) async throws -> [LanguageServerLocation] { + try await withCheckedThrowingContinuation { continuation in + do { + try manager.navigate( + method: method, + fileURL: sourceURL, + text: text, + position: position, + rootURL: rootURL, + completion: { continuation.resume(with: $0) } + ) + } catch { + continuation.resume(throwing: error) + } + } + } + /// Language requests are only delivered through their completion handler, so /// the continuation is resumed exactly once by production code and never by a /// timer. This cannot dangle on a live server: the Rust runtime session owns a @@ -390,6 +505,27 @@ struct RealPhpIntegrationTests { return nil } + /// Captures every regular file and its digest so a real LSP lifecycle cannot + /// silently write into the installed plugin bundle. + private static func snapshotPackage(at packageURL: URL) throws -> PluginPackageSnapshot { + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: packageURL.path) else { + throw CocoaError(.fileNoSuchFile, userInfo: [NSFilePathErrorKey: packageURL.path]) + } + let paths = try fileManager.subpathsOfDirectory(atPath: packageURL.path) + .sorted() + var files: [String: String] = [:] + for relativePath in paths { + let fileURL = packageURL.appendingPathComponent(relativePath) + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: fileURL.path, isDirectory: &isDirectory), + !isDirectory.boolValue else { continue } + let digest = SHA256.hash(data: try Data(contentsOf: fileURL)) + files[relativePath] = digest.map { String(format: "%02x", $0) }.joined() + } + return PluginPackageSnapshot(files: files) + } + private static var repositoryRoot: URL { URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -399,6 +535,10 @@ struct RealPhpIntegrationTests { } } +private struct PluginPackageSnapshot: Equatable { + let files: [String: String] +} + @MainActor private final class RealPhpLanguageRuntime: LanguageProviderRuntime { let descriptor: LanguageProviderDescriptor @@ -413,6 +553,47 @@ private final class RealPhpLanguageRuntime: LanguageProviderRuntime { func makeLanguageServerSession() -> (any LanguageServerSession)? { session } } +@MainActor +private final class RealPhpLanguageProviderRuntimeFactory: LanguageProviderRuntimeFactory { + private let runtime: RealPhpLanguageRuntime + + init(runtime: RealPhpLanguageRuntime) { + self.runtime = runtime + } + + func makeRuntime(for _: LanguageProviderDescriptor) -> (any LanguageProviderRuntime)? { + nil + } + + func makeRuntime( + for _: LanguageProviderDescriptor, + languageServerLaunch _: LanguageServerLaunchDescriptor, + ownerModuleID _: ModuleID + ) -> (any LanguageProviderRuntime)? { + runtime + } +} + +@MainActor +private final class RealPhpWorkspaceModule: LitheModule { + let manifest: ModuleManifest + private let capability = RealPhpWorkspaceCapability() + + init(manifest: ModuleManifest) { + self.manifest = manifest + } + + func activate(context _: ModuleContext) async throws {} + func prepareForSleep() async throws {} + func sleep() async {} + func shutdown() async {} + func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + [.workspaceFoundation: capability] + } +} + +private final class RealPhpWorkspaceCapability: NSObject {} + @MainActor private final class PhpIntegrationWorkspaceModule: LitheModule { let manifest: ModuleManifest diff --git a/rust/lithe-core/src/plugins/mod.rs b/rust/lithe-core/src/plugins/mod.rs index a150f36eb..d734ff971 100644 --- a/rust/lithe-core/src/plugins/mod.rs +++ b/rust/lithe-core/src/plugins/mod.rs @@ -1,13 +1,19 @@ //! Plugin manifest parsing, compatibility checks, and deterministic catalog merging. -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use url::Url; /// Manifest schema understood by this Core build. pub const PLUGIN_MANIFEST_SCHEMA_VERSION: u32 = 1; /// Host/plugin API level required by compatible packages. pub const PLUGIN_API_VERSION: u32 = 1; +fn default_schema_version() -> u32 { + PLUGIN_MANIFEST_SCHEMA_VERSION +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] /// Strict three-component semantic version used for compatibility comparisons. pub struct PluginVersion { @@ -24,10 +30,17 @@ impl PluginVersion { /// rejected because the manifest contract does not define their ordering. pub fn parse(value: &str) -> Option { let mut parts = value.split('.'); + let parse_component = |part: &str| { + (!part.is_empty() + && part.chars().all(|character| character.is_ascii_digit()) + && (part == "0" || !part.starts_with('0'))) + .then(|| part.parse().ok()) + .flatten() + }; let version = Self { - major: parts.next()?.parse().ok()?, - minor: parts.next()?.parse().ok()?, - patch: parts.next()?.parse().ok()?, + major: parse_component(parts.next()?)?, + minor: parse_component(parts.next()?)?, + patch: parse_component(parts.next()?)?, }; parts.next().is_none().then_some(version) } @@ -89,6 +102,149 @@ pub enum PluginValidationError { /// Language identifier whose recognition or module ownership is invalid. language: String, }, + /// A full module declaration disagrees with the compact module ID list. + InvalidModuleDeclaration { + /// Plugin containing the inconsistent declaration. + plugin: String, + /// Stable reason suitable for diagnostics. + detail: String, + }, + /// A language-server/tool manifest is incomplete or unsafe. + InvalidLanguageServerManifest { + /// Plugin owning the tool manifest. + plugin: String, + /// Stable validation detail suitable for diagnostics. + detail: String, + }, + /// The lifecycle action is not valid for the current plugin state. + InvalidLifecycleTransition { + /// Plugin whose state could not advance. + plugin: String, + /// Current state and requested action. + state: PluginLifecycleState, + /// Action that was rejected. + action: PluginLifecycleAction, + }, + /// A plugin cannot be disabled or uninstalled while it owns resources. + ActiveResources { + /// Plugin whose resources are still active. + plugin: String, + /// Stable resource identifiers still bound to the plugin. + resources: Vec, + }, + /// The lifecycle generation cannot be incremented further. + GenerationOverflow { + /// Plugin whose lifecycle exhausted its generation space. + plugin: String, + }, +} + +impl fmt::Display for PluginValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidJson => formatter.write_str("invalid plugin JSON"), + Self::UnsupportedSchema { plugin, version } => { + write!( + formatter, + "plugin {plugin} uses unsupported schema version {version}" + ) + } + Self::UnsupportedApi { plugin, version } => { + write!( + formatter, + "plugin {plugin} uses unsupported API version {version}" + ) + } + Self::InvalidVersion { plugin, value } => { + write!(formatter, "plugin {plugin} has invalid version {value}") + } + Self::IncompatibleHost { plugin } => { + write!(formatter, "plugin {plugin} is incompatible with this host") + } + Self::InvalidEntrypoint { plugin } => { + write!(formatter, "plugin {plugin} has invalid entrypoint metadata") + } + Self::DuplicatePlugin(plugin) => write!(formatter, "duplicate plugin {plugin}"), + Self::DuplicateModule(module) => write!(formatter, "duplicate module {module}"), + Self::EmptyPlugin(plugin) => write!(formatter, "plugin {plugin} declares no modules"), + Self::UnsortedPlugins => formatter.write_str("plugin packages are not sorted"), + Self::UnsortedModules { plugin } => { + write!(formatter, "plugin {plugin} modules are not sorted") + } + Self::InvalidLanguageSupport { plugin, language } => { + write!( + formatter, + "plugin {plugin} has invalid language support {language}" + ) + } + Self::InvalidModuleDeclaration { plugin, detail } => { + write!( + formatter, + "plugin {plugin} has invalid module declaration: {detail}" + ) + } + Self::InvalidLanguageServerManifest { plugin, detail } => { + write!( + formatter, + "plugin {plugin} has invalid language-server manifest: {detail}" + ) + } + Self::InvalidLifecycleTransition { + plugin, + state, + action, + } => { + write!( + formatter, + "plugin {plugin} cannot apply {} in {}", + lifecycle_action_name(*action), + lifecycle_state_name(*state) + ) + } + Self::ActiveResources { plugin, resources } => { + write!( + formatter, + "plugin {plugin} still owns resources: {}", + resources.join(", ") + ) + } + Self::GenerationOverflow { plugin } => { + write!(formatter, "plugin {plugin} lifecycle generation overflowed") + } + } + } +} + +impl std::error::Error for PluginValidationError {} + +fn lifecycle_state_name(state: PluginLifecycleState) -> &'static str { + match state { + PluginLifecycleState::Discovered => "discovered", + PluginLifecycleState::Installing => "installing", + PluginLifecycleState::Installed => "installed", + PluginLifecycleState::Enabling => "enabling", + PluginLifecycleState::Enabled => "enabled", + PluginLifecycleState::Disabling => "disabling", + PluginLifecycleState::Disabled => "disabled", + PluginLifecycleState::Uninstalling => "uninstalling", + PluginLifecycleState::Uninstalled => "uninstalled", + PluginLifecycleState::Failed => "failed", + } +} + +fn lifecycle_action_name(action: PluginLifecycleAction) -> &'static str { + match action { + PluginLifecycleAction::BeginInstall => "beginInstall", + PluginLifecycleAction::CompleteInstall => "completeInstall", + PluginLifecycleAction::Enable => "enable", + PluginLifecycleAction::CompleteEnable => "completeEnable", + PluginLifecycleAction::Disable => "disable", + PluginLifecycleAction::CompleteDisable => "completeDisable", + PluginLifecycleAction::Uninstall => "uninstall", + PluginLifecycleAction::CompleteUninstall => "completeUninstall", + PluginLifecycleAction::Fail => "fail", + PluginLifecycleAction::Reset => "reset", + } } #[derive(Debug, Deserialize)] @@ -106,10 +262,13 @@ pub struct PluginCatalogFixture { pub plugins: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] /// Compatibility and ownership metadata for one plugin package. pub struct PluginPackageManifest { + /// Version of the per-package manifest schema. + #[serde(default = "default_schema_version")] + pub schema_version: u32, /// Stable package identifier used as the catalog key. pub id: String, /// Human-readable name presented by host applications. @@ -126,13 +285,30 @@ pub struct PluginPackageManifest { pub entrypoint: PluginEntrypoint, /// Stable module identifiers owned by this package, in sorted order. #[serde(rename = "moduleIDs")] + #[serde(default)] pub module_ids: Vec, + /// Full module declarations used by an installed plugin's `plugin.json`. + /// Catalog fixtures may use the compact `moduleIDs` representation. + #[serde(default)] + pub modules: Vec, /// Language capabilities contributed by the package. #[serde(default)] pub language_supports: Vec, } -#[derive(Debug, Deserialize)] +/// Module declaration accepted in a plugin-owned manifest. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginModuleManifest { + /// Stable module identifier owned by the plugin package. + pub id: String, + /// Remaining module fields are preserved so Core validation does not erase + /// the host-facing module graph while the generated bindings are migrated. + #[serde(flatten)] + pub metadata: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] /// File recognition and module ownership for one contributed language. pub struct LanguageSupportManifest { @@ -163,7 +339,7 @@ pub struct LanguageSupportManifest { pub debug_module_id: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] /// Half-open host-version interval supported by a plugin. pub struct HostCompatibility { @@ -173,7 +349,7 @@ pub struct HostCompatibility { pub maximum_exclusive: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] /// Plugin publisher identity and the signature relationship required by the host. pub struct PluginVendor { @@ -185,7 +361,7 @@ pub struct PluginVendor { pub signature_requirement: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] /// Mutually exclusive loading metadata for built-in and native-bundle plugins. pub struct PluginEntrypoint { @@ -201,6 +377,276 @@ pub struct PluginEntrypoint { pub bundle_path: Option, } +/// Build-time language-server package metadata owned by one plugin. +/// +/// The archive is fetched and unpacked by the platform adapter, while Core +/// validates the identity, fixed version, checksum shape, and safe relative +/// paths. This keeps download policy out of the host language catalog. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginLanguageServerManifest { + /// Version of this tool manifest schema. + #[serde(default = "default_schema_version")] + pub schema_version: u32, + /// Plugin that owns the tool and its lifecycle. + #[serde(rename = "pluginID")] + pub plugin_id: String, + /// Language recognized by this tool. + #[serde(rename = "languageID")] + pub language_id: String, + /// Stable tool identifier, for example `intelephense`. + #[serde(rename = "toolID")] + pub tool_id: String, + /// Exact upstream version included in the package. + pub version: String, + /// Fixed HTTPS archive source used at build time. + #[serde(rename = "archiveURL")] + pub archive_url: String, + /// SHA-256 digest of the complete upstream archive. + #[serde(rename = "archiveSHA256")] + pub archive_sha256: String, + /// Archive format understood by the build script. + pub archive_format: String, + /// Archive root stripped before installation. + pub archive_root: String, + /// Entrypoint path inside the upstream archive. + pub entrypoint: String, + /// Launcher path inside the installed plugin bundle, relative to its + /// language-server resource directory. + pub launcher_relative_path: String, + /// License file path inside the upstream archive. + pub license: String, + /// Ordered launcher arguments passed to the generic LSP runtime. + #[serde(default)] + pub arguments: Vec, +} + +/// Stable lifecycle states for resources owned by an installed plugin. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum PluginLifecycleState { + /// Metadata has been discovered but no package is installed. + Discovered, + /// Package verification and atomic installation are in progress. + Installing, + /// Package is installed and can be enabled. + Installed, + /// Module activation is in progress. + Enabling, + /// Plugin modules may own capabilities and resources. + Enabled, + /// Disable is stopping owned resources. + Disabling, + /// Plugin remains installed but owns no active resources. + Disabled, + /// Package removal is in progress. + Uninstalling, + /// No package remains installed. + Uninstalled, + /// A terminal failure requires repair or reinstall. + Failed, +} + +/// User or adapter action applied to one plugin lifecycle. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum PluginLifecycleAction { + /// Begin atomic package installation. + BeginInstall, + /// Commit a verified package installation. + CompleteInstall, + /// Begin module activation. + Enable, + /// Commit module activation. + CompleteEnable, + /// Begin stopping module resources. + Disable, + /// Commit module shutdown. + CompleteDisable, + /// Begin package removal. + Uninstall, + /// Commit package removal. + CompleteUninstall, + /// Enter failed state after an operation error. + Fail, + /// Return a resource-free failed lifecycle to discovery. + Reset, +} + +/// One deterministic lifecycle transition emitted to platform adapters. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginLifecycleEvent { + /// Plugin owning the transition. + pub plugin_id: String, + /// Monotonic generation incremented for every accepted action. + pub generation: u64, + /// Operation identifier supplied by the caller. + pub operation_id: String, + /// State before the action. + pub previous_state: PluginLifecycleState, + /// State after the action. + pub state: PluginLifecycleState, + /// Resources still owned after the transition. + pub resources: Vec, +} + +/// Pure Core state machine for plugin package and resource ownership. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginLifecycle { + /// Stable plugin identifier whose resources are being reduced. + pub plugin_id: String, + /// Current lifecycle state. + pub state: PluginLifecycleState, + /// Monotonic transition generation supplied to stale-result guards. + pub generation: u64, + /// Core-owned sessions and processes that must stop before disable/uninstall. + #[serde(default)] + pub resources: BTreeSet, +} + +/// JSON request for the stateless lifecycle reducer exposed by Core. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginLifecycleRequest { + /// Lifecycle snapshot returned by the previous transition. + pub lifecycle: PluginLifecycle, + /// Requested state transition. + pub action: PluginLifecycleAction, + /// Caller operation identity copied into the transition event. + #[serde(default)] + pub operation_id: String, +} + +/// JSON response containing the new lifecycle snapshot and its event. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginLifecycleResponse { + /// Updated lifecycle snapshot after the accepted action. + pub lifecycle: PluginLifecycle, + /// Event describing the accepted transition. + pub event: PluginLifecycleEvent, +} + +impl PluginLifecycle { + /// Creates a lifecycle in the discovered state. + pub fn new(plugin_id: impl Into) -> Self { + Self { + plugin_id: plugin_id.into(), + state: PluginLifecycleState::Discovered, + generation: 0, + resources: BTreeSet::new(), + } + } + + /// Binds one Core-owned resource, such as an LSP session or process. + pub fn bind_resource( + &mut self, + resource: impl Into, + ) -> Result<(), PluginValidationError> { + if !matches!( + self.state, + PluginLifecycleState::Enabled | PluginLifecycleState::Enabling + ) { + return Err(PluginValidationError::InvalidLifecycleTransition { + plugin: self.plugin_id.clone(), + state: self.state, + action: PluginLifecycleAction::CompleteEnable, + }); + } + let resource = resource.into(); + if !resource.is_empty() { + self.resources.insert(resource); + } + Ok(()) + } + + /// Releases one resource after its platform process/session has stopped. + pub fn unbind_resource(&mut self, resource: &str) { + self.resources.remove(resource); + } + + /// Applies one lifecycle action and returns the event consumed by the host. + pub fn apply( + &mut self, + action: PluginLifecycleAction, + operation_id: impl Into, + ) -> Result { + if matches!( + action, + PluginLifecycleAction::Disable + | PluginLifecycleAction::CompleteDisable + | PluginLifecycleAction::Uninstall + | PluginLifecycleAction::CompleteUninstall + | PluginLifecycleAction::Fail + | PluginLifecycleAction::Reset + ) && !self.resources.is_empty() + { + return Err(PluginValidationError::ActiveResources { + plugin: self.plugin_id.clone(), + resources: self.resources.iter().cloned().collect(), + }); + } + let next = match (self.state, action) { + ( + PluginLifecycleState::Discovered | PluginLifecycleState::Uninstalled, + PluginLifecycleAction::BeginInstall, + ) => PluginLifecycleState::Installing, + (PluginLifecycleState::Installing, PluginLifecycleAction::CompleteInstall) => { + PluginLifecycleState::Installed + } + ( + PluginLifecycleState::Installed | PluginLifecycleState::Disabled, + PluginLifecycleAction::Enable, + ) => PluginLifecycleState::Enabling, + (PluginLifecycleState::Enabling, PluginLifecycleAction::CompleteEnable) => { + PluginLifecycleState::Enabled + } + (PluginLifecycleState::Enabled, PluginLifecycleAction::Disable) => { + PluginLifecycleState::Disabling + } + (PluginLifecycleState::Disabling, PluginLifecycleAction::CompleteDisable) => { + PluginLifecycleState::Disabled + } + ( + PluginLifecycleState::Installed | PluginLifecycleState::Disabled, + PluginLifecycleAction::Uninstall, + ) => PluginLifecycleState::Uninstalling, + (PluginLifecycleState::Uninstalling, PluginLifecycleAction::CompleteUninstall) => { + PluginLifecycleState::Uninstalled + } + (_, PluginLifecycleAction::Fail) => PluginLifecycleState::Failed, + (PluginLifecycleState::Failed, PluginLifecycleAction::Reset) => { + PluginLifecycleState::Discovered + } + _ => { + return Err(PluginValidationError::InvalidLifecycleTransition { + plugin: self.plugin_id.clone(), + state: self.state, + action, + }) + } + }; + let previous_state = self.state; + let next_generation = self.generation.checked_add(1).ok_or_else(|| { + PluginValidationError::GenerationOverflow { + plugin: self.plugin_id.clone(), + } + })?; + self.state = next; + self.generation = next_generation; + Ok(PluginLifecycleEvent { + plugin_id: self.plugin_id.clone(), + generation: self.generation, + operation_id: operation_id.into(), + previous_state, + state: next, + resources: self.resources.iter().cloned().collect(), + }) + } +} + /// Validates a complete catalog and returns the owning plugin for every module. /// /// Validation also enforces deterministic ordering, host/API compatibility, @@ -265,7 +711,10 @@ pub fn validate_plugin_catalog_json( if plugin.display_name.is_empty() || plugin.vendor.id.is_empty() || plugin.vendor.display_name.is_empty() - || plugin.vendor.signature_requirement != "sameTeamAsHost" + || !matches!( + plugin.vendor.signature_requirement.as_str(), + "sameTeamAsHost" | "publisherPackage" + ) || !valid_entrypoint(&plugin.entrypoint) { return Err(PluginValidationError::InvalidEntrypoint { plugin: plugin.id }); @@ -289,8 +738,151 @@ pub fn validate_plugin_catalog_json( Ok(module_owners) } +/// Validates one plugin directory's `plugin.json` using the same rules as a +/// merged catalog. The platform adapter may call this before loading a Bundle. +pub fn validate_plugin_manifest_json( + input: &str, + host_version: PluginVersion, +) -> Result { + let manifest: PluginPackageManifest = + serde_json::from_str(input).map_err(|_| PluginValidationError::InvalidJson)?; + if manifest.schema_version != PLUGIN_MANIFEST_SCHEMA_VERSION { + return Err(PluginValidationError::UnsupportedSchema { + plugin: manifest.id.clone(), + version: manifest.schema_version, + }); + } + if manifest.api_version != PLUGIN_API_VERSION { + return Err(PluginValidationError::UnsupportedApi { + plugin: manifest.id.clone(), + version: manifest.api_version, + }); + } + let _ = parse_version(&manifest.id, &manifest.version)?; + let minimum = parse_version(&manifest.id, &manifest.host_compatibility.minimum)?; + let maximum = manifest + .host_compatibility + .maximum_exclusive + .as_deref() + .map(|value| parse_version(&manifest.id, value)) + .transpose()?; + if host_version < minimum || maximum.is_some_and(|value| host_version >= value) { + return Err(PluginValidationError::IncompatibleHost { + plugin: manifest.id.clone(), + }); + } + validate_manifest_shape(&manifest)?; + Ok(manifest) +} + +fn validate_manifest_shape(manifest: &PluginPackageManifest) -> Result<(), PluginValidationError> { + if manifest.id.is_empty() + || manifest.display_name.is_empty() + || manifest.vendor.id.is_empty() + || manifest.vendor.display_name.is_empty() + || !matches!( + manifest.vendor.signature_requirement.as_str(), + "sameTeamAsHost" | "publisherPackage" + ) + || !valid_entrypoint(&manifest.entrypoint) + || manifest.module_ids().is_empty() + { + return Err(PluginValidationError::InvalidEntrypoint { + plugin: manifest.id.clone(), + }); + } + let compact_module_ids = manifest.module_ids.clone(); + let full_module_ids: Vec = manifest + .modules + .iter() + .map(|module| module.id.clone()) + .collect(); + if !compact_module_ids.is_empty() + && !full_module_ids.is_empty() + && compact_module_ids != full_module_ids + { + return Err(PluginValidationError::InvalidModuleDeclaration { + plugin: manifest.id.clone(), + detail: "moduleIDs and modules disagree".into(), + }); + } + let module_ids = manifest.module_ids(); + if !strictly_sorted(&module_ids) + || module_ids.iter().any(|module| !valid_identifier(module)) + || manifest.modules.iter().any(|module| module.id.is_empty()) + { + return Err(PluginValidationError::UnsortedModules { + plugin: manifest.id.clone(), + }); + } + validate_language_supports(manifest) +} + +impl PluginPackageManifest { + /// Returns stable module IDs declared by this package. + pub fn module_ids(&self) -> Vec { + if self.module_ids.is_empty() { + self.modules + .iter() + .map(|module| module.id.clone()) + .collect() + } else { + self.module_ids.clone() + } + } +} + +/// Validates a plugin-owned `language-server.json` document. +pub fn validate_language_server_manifest_json( + input: &str, + plugin_id: &str, +) -> Result { + let manifest: PluginLanguageServerManifest = serde_json::from_str(input).map_err(|error| { + PluginValidationError::InvalidLanguageServerManifest { + plugin: plugin_id.into(), + detail: format!("invalid JSON: {error}"), + } + })?; + let valid_sha = manifest.archive_sha256.len() == 64 + && manifest + .archive_sha256 + .chars() + .all(|value| value.is_ascii_hexdigit()); + let valid_url = Url::parse(&manifest.archive_url).is_ok_and(|url| { + url.scheme() == "https" + && url.host_str().is_some_and(|host| !host.is_empty()) + && url.username().is_empty() + && url.password().is_none() + && !url.path().is_empty() + }); + let valid = manifest.schema_version == PLUGIN_MANIFEST_SCHEMA_VERSION + && manifest.plugin_id == plugin_id + && valid_identifier(&manifest.language_id) + && valid_identifier(&manifest.tool_id) + && PluginVersion::parse(&manifest.version).is_some() + && valid_url + && valid_sha + && manifest.archive_format == "tarGzip" + && valid_relative_path(&manifest.archive_root) + && valid_relative_path(&manifest.entrypoint) + && valid_relative_path(&manifest.launcher_relative_path) + && valid_relative_path(&manifest.license) + && !manifest + .arguments + .iter() + .any(|argument| argument.contains('\0')); + if !valid { + return Err(PluginValidationError::InvalidLanguageServerManifest { + plugin: plugin_id.into(), + detail: "schema, identity, source, checksum, or relative path is invalid".into(), + }); + } + Ok(manifest) +} + fn validate_language_supports(plugin: &PluginPackageManifest) -> Result<(), PluginValidationError> { - let owned_modules: BTreeSet<&str> = plugin.module_ids.iter().map(String::as_str).collect(); + let module_ids = plugin.module_ids(); + let owned_modules: BTreeSet<&str> = module_ids.iter().map(String::as_str).collect(); let mut language_ids = BTreeSet::new(); for support in &plugin.language_supports { let module_ids: Vec<&str> = [ @@ -379,7 +971,18 @@ fn valid_entrypoint(entrypoint: &PluginEntrypoint) -> bool { fn valid_relative_path(value: &str) -> bool { !value.is_empty() && !value.starts_with('/') + && !value.starts_with('\\') + && !value.contains('\\') + && !value.contains('\0') + && !value.as_bytes().get(1).is_some_and(|byte| *byte == b':') && !value .split('/') - .any(|component| component == ".." || component.is_empty()) + .any(|component| component == ".." || component == "." || component.is_empty()) +} + +fn valid_identifier(value: &str) -> bool { + !value.is_empty() + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') + }) } diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 16957ad27..ac45dd604 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -36,6 +36,12 @@ pub enum CoreCommand { IdeHostControl, /// Reports the Core and protocol versions (`core.ping`). Ping, + /// Validates one installed plugin `plugin.json` against the Rust contract. + PluginValidateManifest, + /// Validates one plugin-owned `language-server.json` document. + PluginValidateLanguageServer, + /// Applies one deterministic plugin installation/enablement lifecycle action. + PluginLifecycle, /// Detects Node.js and npm and lists catalog agent installs (`agent.status`). AgentStatus, /// Installs a catalog ACP adapter with the user's npm (`agent.install`). @@ -339,6 +345,9 @@ impl CoreCommand { pub fn parse(value: &str) -> Option { match value { "core.ping" => Some(Self::Ping), + "plugin.validateManifest" => Some(Self::PluginValidateManifest), + "plugin.validateLanguageServer" => Some(Self::PluginValidateLanguageServer), + "plugin.lifecycle" => Some(Self::PluginLifecycle), "ideHost.control" => Some(Self::IdeHostControl), "agent.status" => Some(Self::AgentStatus), "agent.install" => Some(Self::AgentInstall), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 77f51e1c5..85610492e 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -123,6 +123,116 @@ fn execute(request: &str) -> CoreResponse { "coreVersion": env!("CARGO_PKG_VERSION") }), ), + CoreCommand::PluginValidateManifest => { + let host_version = parsed + .payload + .get("hostVersion") + .and_then(|value| value.as_str()); + let manifest = parsed.payload.get("manifest"); + match host_version + .zip(manifest) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Plugin manifest validation requires hostVersion and manifest", + ) + }) + .and_then(|(host_version, manifest)| { + let host_version = crate::plugins::PluginVersion::parse(host_version) + .ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid host version") + })?; + let manifest = serde_json::to_string(manifest).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid plugin manifest") + .with_details(error.to_string()) + })?; + crate::plugins::validate_plugin_manifest_json(&manifest, host_version).map_err( + |error| { + CoreError::new( + ErrorCode::ParseFailed, + "Plugin manifest validation failed", + ) + .with_details(error.to_string()) + }, + ) + }) { + Ok(manifest) => CoreResponse::success( + id, + serde_json::to_value(manifest).expect("plugin manifest should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::PluginValidateLanguageServer => { + let plugin_id = parsed + .payload + .get("pluginID") + .and_then(|value| value.as_str()); + let manifest = parsed.payload.get("manifest"); + match plugin_id + .zip(manifest) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Language-server validation requires pluginID and manifest", + ) + }) + .and_then(|(plugin_id, manifest)| { + let manifest = serde_json::to_string(manifest).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid language-server manifest", + ) + .with_details(error.to_string()) + })?; + crate::plugins::validate_language_server_manifest_json(&manifest, plugin_id) + .map_err(|error| { + CoreError::new( + ErrorCode::ParseFailed, + "Language-server manifest validation failed", + ) + .with_details(error.to_string()) + }) + }) { + Ok(manifest) => CoreResponse::success( + id, + serde_json::to_value(manifest).expect("language-server manifest should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::PluginLifecycle => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid plugin lifecycle request", + ) + .with_details(error.to_string()) + }) + .and_then(|mut request| { + let event = request + .lifecycle + .apply(request.action, request.operation_id) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Plugin lifecycle transition failed", + ) + .with_details(error.to_string()) + })?; + Ok(crate::plugins::PluginLifecycleResponse { + lifecycle: request.lifecycle, + event, + }) + }) { + Ok(response) => CoreResponse::success( + id, + serde_json::to_value(response).expect("plugin lifecycle should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::AgentStatus => agent_response(id, parsed.payload, crate::agent::status), CoreCommand::AgentParseProviderConfiguration => { match crate::ai::parse_provider_configuration(parsed.payload) { diff --git a/rust/lithe-core/src/tests/plugins.rs b/rust/lithe-core/src/tests/plugins.rs index 81d8c88fe..5593b4101 100644 --- a/rust/lithe-core/src/tests/plugins.rs +++ b/rust/lithe-core/src/tests/plugins.rs @@ -1,4 +1,9 @@ -use crate::plugins::{validate_plugin_catalog_json, PluginValidationError, PluginVersion}; +use crate::plugins::{ + validate_language_server_manifest_json, validate_plugin_catalog_json, + validate_plugin_manifest_json, PluginLifecycle, PluginLifecycleAction, PluginLifecycleState, + PluginValidationError, PluginVersion, +}; +use serde_json::{json, Value}; const OFFICIAL_PLUGINS: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../shared/fixtures/plugins/official-v1.json" @@ -7,6 +12,18 @@ const LANGUAGE_SUPPORT_FIXTURE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../shared/fixtures/plugins/language-support-v1.json" )); +const PHP_PLUGIN_MANIFEST: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../Plugins/mac/Official/PhpSupport/plugin.json" +)); +const PHP_LANGUAGE_SERVER_MANIFEST: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../Plugins/mac/Official/PhpSupport/language-server.json" +)); +const LIFECYCLE_FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/plugins/lifecycle-v1.json" +)); #[test] fn official_plugin_catalog_is_valid_and_contains_only_released_downloads() { let owners = validate_plugin_catalog_json( @@ -61,3 +78,235 @@ fn incompatible_host_is_rejected_deterministically() { } ); } + +#[test] +fn php_plugin_manifests_are_validated_by_rust_core() { + let host = PluginVersion { + major: 0, + minor: 3, + patch: 0, + }; + let manifest = validate_plugin_manifest_json(PHP_PLUGIN_MANIFEST, host) + .expect("PHP plugin.json should use the Core contract"); + assert_eq!(manifest.id, "dev.lithe.plugin.php-support"); + assert_eq!(manifest.module_ids().len(), 2); + assert_eq!(manifest.modules[0].metadata["displayName"], "PHP Execution"); + let language_server = + validate_language_server_manifest_json(PHP_LANGUAGE_SERVER_MANIFEST, &manifest.id) + .expect("PHP language-server.json should use the Core contract"); + assert_eq!(language_server.language_id, "php"); + assert_eq!(language_server.arguments, ["--stdio"]); +} + +#[test] +fn plugin_manifest_rejects_conflicting_compact_and_full_module_declarations() { + let mut value: Value = serde_json::from_str(PHP_PLUGIN_MANIFEST).unwrap(); + value["moduleIDs"] = json!(["dev.lithe.language.php.other"]); + let error = validate_plugin_manifest_json( + &value.to_string(), + PluginVersion { + major: 0, + minor: 3, + patch: 0, + }, + ) + .unwrap_err(); + assert_eq!( + error, + PluginValidationError::InvalidModuleDeclaration { + plugin: "dev.lithe.plugin.php-support".into(), + detail: "moduleIDs and modules disagree".into(), + } + ); +} + +#[test] +fn language_server_manifest_rejects_noncanonical_versions_and_paths() { + let mut value: Value = serde_json::from_str(PHP_LANGUAGE_SERVER_MANIFEST).unwrap(); + value["version"] = json!("01.15.1"); + value["launcherRelativePath"] = json!("bin/../intelephense"); + let error = + validate_language_server_manifest_json(&value.to_string(), "dev.lithe.plugin.php-support") + .unwrap_err(); + assert!(matches!( + error, + PluginValidationError::InvalidLanguageServerManifest { .. } + )); +} + +#[test] +fn plugin_lifecycle_requires_resources_to_stop_before_disable_or_uninstall() { + let mut lifecycle = PluginLifecycle::new("dev.lithe.plugin.php-support"); + lifecycle + .apply(PluginLifecycleAction::BeginInstall, "install-1") + .unwrap(); + lifecycle + .apply(PluginLifecycleAction::CompleteInstall, "install-1") + .unwrap(); + lifecycle + .apply(PluginLifecycleAction::Enable, "enable-1") + .unwrap(); + lifecycle + .apply(PluginLifecycleAction::CompleteEnable, "enable-1") + .unwrap(); + lifecycle.bind_resource("lsp-session-1").unwrap(); + let error = lifecycle + .apply(PluginLifecycleAction::Disable, "disable-1") + .unwrap_err(); + assert_eq!( + error, + PluginValidationError::ActiveResources { + plugin: "dev.lithe.plugin.php-support".into(), + resources: vec!["lsp-session-1".into()] + } + ); + lifecycle.unbind_resource("lsp-session-1"); + lifecycle + .apply(PluginLifecycleAction::Disable, "disable-2") + .unwrap(); + let event = lifecycle + .apply(PluginLifecycleAction::CompleteDisable, "disable-2") + .unwrap(); + assert_eq!(event.state, PluginLifecycleState::Disabled); + assert_eq!(event.generation, 6); +} + +#[test] +fn plugin_lifecycle_cannot_hide_active_resources_in_failure_or_reset() { + let mut lifecycle = PluginLifecycle::new("dev.lithe.plugin.php-support"); + for action in [ + PluginLifecycleAction::BeginInstall, + PluginLifecycleAction::CompleteInstall, + PluginLifecycleAction::Enable, + PluginLifecycleAction::CompleteEnable, + ] { + lifecycle.apply(action, "operation").unwrap(); + } + lifecycle.bind_resource("lsp-session-1").unwrap(); + for action in [PluginLifecycleAction::Fail, PluginLifecycleAction::Reset] { + assert!(matches!( + lifecycle.apply(action, "operation"), + Err(PluginValidationError::ActiveResources { .. }) + )); + } +} + +#[test] +fn plugin_lifecycle_generation_overflow_does_not_partially_apply_state() { + let mut lifecycle = PluginLifecycle { + plugin_id: "dev.lithe.plugin.php-support".into(), + state: PluginLifecycleState::Installed, + generation: u64::MAX, + resources: Default::default(), + }; + + assert!(matches!( + lifecycle.apply(PluginLifecycleAction::Enable, "enable-overflow"), + Err(PluginValidationError::GenerationOverflow { .. }) + )); + assert_eq!(lifecycle.state, PluginLifecycleState::Installed); + assert_eq!(lifecycle.generation, u64::MAX); +} + +#[test] +fn uninstalled_plugin_can_begin_a_fresh_install() { + let mut lifecycle = PluginLifecycle::new("dev.lithe.plugin.php-support"); + for action in [ + PluginLifecycleAction::BeginInstall, + PluginLifecycleAction::CompleteInstall, + PluginLifecycleAction::Uninstall, + PluginLifecycleAction::CompleteUninstall, + ] { + lifecycle.apply(action, "operation").unwrap(); + } + + let event = lifecycle + .apply(PluginLifecycleAction::BeginInstall, "reinstall") + .unwrap(); + assert_eq!(event.previous_state, PluginLifecycleState::Uninstalled); + assert_eq!(event.state, PluginLifecycleState::Installing); +} + +#[test] +fn plugin_lifecycle_wire_fixture_is_deterministic() { + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct Case { + lifecycle: PluginLifecycle, + action: PluginLifecycleAction, + operation_id: String, + bind_resource: Option, + expected_state: PluginLifecycleState, + expected_generation: u64, + #[serde(default)] + expected_resources: Vec, + } + + for case in serde_json::from_str::>(LIFECYCLE_FIXTURE).unwrap() { + let mut lifecycle = case.lifecycle; + if let Some(resource) = case.bind_resource { + lifecycle.bind_resource(resource).unwrap(); + } + let event = lifecycle.apply(case.action, case.operation_id).unwrap(); + assert_eq!(event.state, case.expected_state); + assert_eq!(event.generation, case.expected_generation); + assert_eq!(event.resources, case.expected_resources); + } +} + +#[test] +fn plugin_contract_commands_are_executable_through_the_shared_dispatcher() { + let manifest: Value = serde_json::from_str(PHP_PLUGIN_MANIFEST).unwrap(); + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({ + "id": "manifest-1", + "command": "plugin.validateManifest", + "payload": { + "hostVersion": "0.3.0", + "manifest": manifest + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true); + assert_eq!(response["data"]["id"], "dev.lithe.plugin.php-support"); + + let language_server: Value = serde_json::from_str(PHP_LANGUAGE_SERVER_MANIFEST).unwrap(); + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({ + "id": "language-server-1", + "command": "plugin.validateLanguageServer", + "payload": { + "pluginID": "dev.lithe.plugin.php-support", + "manifest": language_server + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true); + assert_eq!(response["data"]["languageID"], "php"); + + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({ + "id": "lifecycle-1", + "command": "plugin.lifecycle", + "payload": { + "lifecycle": { + "pluginId": "dev.lithe.plugin.php-support", + "state": "discovered", + "generation": 0, + "resources": [] + }, + "action": "beginInstall", + "operationId": "install-1" + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true); + assert_eq!(response["data"]["lifecycle"]["state"], "installing"); + assert_eq!(response["data"]["event"]["operationId"], "install-1"); +} diff --git a/scripts/test-rust-core-comments.mjs b/scripts/test-rust-core-comments.mjs index 885ad20c1..0dde2b001 100644 --- a/scripts/test-rust-core-comments.mjs +++ b/scripts/test-rust-core-comments.mjs @@ -27,7 +27,10 @@ for (const locale of ["C", "en_US.UTF-8"]) { copyFileSync(path.join(scripts, "verify-rust-core-comments.sh"), path.join(root, "scripts/verify-rust-core-comments.sh")); writeFileSync(path.join(root, "rust/lithe-core/src/lib.rs"), `//! Fixture module.\n${source}\n`); // Exercise the no-ripgrep path as well as the system awk used by CI. - for (const tool of ["dirname", "find", "awk", "ruby", "sed", "sort"]) { + // Ruby's macOS rbconfig invokes uname while bootstrapping. Keep the + // isolated PATH complete so the fixture tests the checker, not Ruby's + // host discovery. + for (const tool of ["dirname", "find", "awk", "ruby", "sed", "sort", "uname"]) { const located = spawnSync("/bin/sh", ["-c", `command -v ${tool}`], { encoding: "utf8", timeout: 2_000 }); assert.equal(located.status, 0, `Required checker tool: ${tool}`); symlinkSync(located.stdout.trim(), path.join(bin, tool)); diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index d31596308..8a24075f1 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -304,6 +304,9 @@ package manager owns the download and Lithe does not infer bytes from logs. | Command | Purpose | | --- | --- | | `core.ping` | Verify the ABI and protocol version | +| `plugin.validateManifest` | Validate one plugin-owned `plugin.json` against the host, API, entrypoint, module ownership, and language-support contract | +| `plugin.validateLanguageServer` | Validate one plugin-owned `language-server.json` and its fixed archive/launcher paths | +| `plugin.lifecycle` | Reduce one plugin installation/enablement transition while preventing disable/uninstall with live owned resources | | `community.discourse.auth.begin` | Create an ephemeral RSA-OAEP authorization session and return the Discourse browser URL | | `community.discourse.auth.complete` | Decrypt, validate, and consume one Discourse user API key callback | | `community.discourse.auth.revoke` | Revoke the current Discourse user API key | @@ -1376,6 +1379,63 @@ socket or process alive long enough to flush the frame, then closes it and calls `debug.destroySession`. A session allocates no process, socket, timer, or background task, and no session exists until Debug is used. +### Plugin package contract and lifecycle + +Plugin manifests and language-server manifests are executable Rust Core +contracts. The platform reads files from an installed package and passes their +JSON to Core; Swift and TypeScript adapters may decode the result for display, +but they must not add a second acceptance rule. `plugin.validateManifest` +accepts `{ "hostVersion": "0.3.0", "manifest": { ...plugin.json... } }` and +returns the normalized manifest when it is valid. It checks the schema and API +versions, strict three-component versions, host compatibility, publisher +signature policy, native/built-in entrypoint shape, sorted owned modules, and +that every language capability points only at modules in that same package. +The plugin manifest may use the installed-package `modules` declarations or the +compact `moduleIDs` fixture representation; both describe the same ownership +boundary. + +`plugin.validateLanguageServer` accepts +`{ "pluginID": "...", "manifest": { ...language-server.json... } }` and +returns the normalized tool description. Its source must be HTTPS, its archive +checksum must be a 64-character SHA-256 value, its archive format must be a +known supported format, and archive, entrypoint, launcher, and license paths +must be non-empty relative paths without `..` components. Launcher arguments +are ordered data and cannot contain NUL. The manifest carries the plugin ID, +language ID, tool ID, exact version, fixed source archive, launcher path and +arguments; it never carries a machine-specific executable path or a writable +cache location. Downloading, checksum calculation, archive extraction, code +signing, and executable discovery remain platform adapter work. + +`plugin.lifecycle` is a stateless reducer. Its request contains the previous +`lifecycle`, one action, and an `operationId`; the response returns the updated +snapshot plus an event with previous/current state, monotonic generation and a +deterministically sorted resource list. The valid path is +`discovered -> installing -> installed -> enabling -> enabled`, followed by +`disabling -> disabled` or `uninstalling -> uninstalled`; an uninstalled package +may begin a fresh install. `fail` moves any state to `failed`, and only `reset` +can return it to `discovered`. LSP sessions, +language-server processes, test runners and other plugin-owned resources are +bound by opaque resource IDs while the plugin is enabling or enabled. Core +rejects shutdown, uninstall, failure, or reset transitions while any resource +remains bound; the host must stop the resource, unbind it, and then retry the +transition. This is the +wire-level rule behind the UI's optional “保留语言服务器” choice: retaining a +tool means retaining an explicitly managed plugin resource and its package +record, never leaving an unmanaged process or copying it into a host-wide +language-tools directory. +The canonical transition cases are also kept in +`shared/fixtures/plugins/lifecycle-v1.json` so Swift and Windows adapters can +exercise the same JSON names and generation semantics. + +The PHP macOS package is the first consumer. Its `plugin.json`, +`language-server.json`, module declarations and LSP lifecycle are under +`Plugins/mac/Official/PhpSupport`; `RealPhpIntegrationTests` starts a real +Intelephense process and verifies initialize/readiness, completion, hover, +definition, references, diagnostics, document close, stop, restart, and final +process cleanup. This fixture is intentionally macOS-only for the migration +slice; Windows consumes the same Core command contract when its plugin package +is migrated. + `lsp.builtinCompletions`, `lsp.builtinHover`, and `lsp.builtinNavigation` are the no-process lightweight language path. They accept current-file text, an absolute `filePath`, and a zero-based LSP position. Completion returns diff --git a/shared/fixtures/plugins/lifecycle-v1.json b/shared/fixtures/plugins/lifecycle-v1.json new file mode 100644 index 000000000..a9ad7ca06 --- /dev/null +++ b/shared/fixtures/plugins/lifecycle-v1.json @@ -0,0 +1,69 @@ +[ + { + "name": "begin-install", + "lifecycle": { + "pluginId": "dev.lithe.plugin.php-support", + "state": "discovered", + "generation": 0, + "resources": [] + }, + "action": "beginInstall", + "operationId": "install-1", + "expectedState": "installing", + "expectedGeneration": 1 + }, + { + "name": "complete-install", + "lifecycle": { + "pluginId": "dev.lithe.plugin.php-support", + "state": "installing", + "generation": 1, + "resources": [] + }, + "action": "completeInstall", + "operationId": "install-1", + "expectedState": "installed", + "expectedGeneration": 2 + }, + { + "name": "enable-and-bind", + "lifecycle": { + "pluginId": "dev.lithe.plugin.php-support", + "state": "enabling", + "generation": 3, + "resources": [] + }, + "action": "completeEnable", + "operationId": "enable-1", + "bindResource": "lsp-session-1", + "expectedState": "enabled", + "expectedGeneration": 4, + "expectedResources": ["lsp-session-1"] + }, + { + "name": "disable-after-resource-stop", + "lifecycle": { + "pluginId": "dev.lithe.plugin.php-support", + "state": "enabled", + "generation": 4, + "resources": [] + }, + "action": "disable", + "operationId": "disable-1", + "expectedState": "disabling", + "expectedGeneration": 5 + }, + { + "name": "reinstall-after-uninstall", + "lifecycle": { + "pluginId": "dev.lithe.plugin.php-support", + "state": "uninstalled", + "generation": 4, + "resources": [] + }, + "action": "beginInstall", + "operationId": "install-2", + "expectedState": "installing", + "expectedGeneration": 5 + } +] diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index e1a5518f5..822f70909 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -2740,7 +2740,7 @@ "macos/Tests/LitheTests/RealPhpIntegrationTests.swift" ], "implementationStatus": "implemented", - "verificationStatus": "pending" + "verificationStatus": "verified" }, "windows": { "evidence": [ @@ -2995,6 +2995,34 @@ }, "owner": "Run/Debug", "verification": "在双端运行 Spring Boot HTTP 服务,修改方法体并更新,检查响应、PID、DevTools 日志与断点暂停状态;覆盖编译失败、JVM 拒绝结构修改、连续点击、停止/重启、依赖路径变化。对比运行前后安装目录清单/哈希。Linux 仅完成共享 Core 和前端测试,原生验证待完成。" + }, + { + "id": "rust-core-plugin-contract", + "area": "扩展", + "group": "插件与语言服务", + "capability": "Rust Core 校验插件清单、语言服务器清单并约束插件资源生命周期", + "owner": "Core / Extensions", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "verified", + "evidence": [ + "rust/lithe-core/src/plugins/mod.rs", + "rust/lithe-core/src/protocol/command.rs", + "rust/lithe-core/src/runtime/dispatcher.rs", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerPackageValidator.swift", + "macos/Tests/LitheTests/RealPhpIntegrationTests.swift", + "shared/contracts/rust-core-api.md" + ] + }, + "windows": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-core/src/plugins/mod.rs", + "windows/tauri/src/extensions" + ] + }, + "verification": "macOS 使用 PHP 插件清单和真实 Intelephense 完成清单校验、initialize、补全、Hover、定义、引用、诊断、关闭、停止、重启和进程清理;Windows 迁移插件包后使用同一 Core 命令和生命周期 fixture 完成对应验证。" } ] }