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/9] 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/9] 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/9] 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/9] 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 455818b6c9927575510e36255dc04b4336a90afa Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 2 Oct 2026 18:33:24 +0800 Subject: [PATCH 5/9] fix(macos): skip unsigned debug PHP plugin packages --- ...acos-update-signing-and-release-strategy.md | 9 ++++++--- Plugins/mac/README.md | 9 +++++++-- docs/ci-builds.md | 2 +- scripts/build-official-plugins.sh | 18 +++++++++--------- scripts/verify-official-plugins.sh | 13 ++++++++++++- 5 files changed, 35 insertions(+), 16 deletions(-) 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 37ebefdfa..12f2c74e7 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 @@ -41,7 +41,8 @@ PHP Support 作为独立 GitHub Release asset 发布,不再把“能否使用 再验证完整包清单、插件 ID 和版本,任一文件被替换都会拒绝安装。 stable 和 preview 工作流都必须配置同一 repository secret -`LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`;私钥只在构建 runner 的标准输入中使用,不能写入 +`LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`。它是与客户端内置公钥匹配的 base64 编码 32 字节 +Ed25519 私钥;可用 `gh secret set LITHE_PLUGIN_PACKAGE_PRIVATE_KEY --repo 1lck/Lithe-IDEA < /secure/path/lithe-plugin-package-private-key.base64` 写入 GitHub,私钥只在构建 runner 的标准输入中使用,不能写入 仓库、release asset 或日志。公钥轮换需要先发布能识别新 key ID 的客户端,再更新 secret 并重新生成包,不能只替换 GitHub secret。 @@ -135,8 +136,10 @@ bundle identifier、展示版本、可执行文件架构、渠道和最低系统 `SPARKLE_PRIVATE_KEY`,替换 repository secret 本身不够,必须单独 规划迁移(重签或双签过渡期),否则旧客户端会拒绝新签名的更新。 - 独立插件包的安装安全性同样依赖客户端内置的 publisher 公钥;没有匹配 secret - 的构建会在 release workflow 中失败,而 debug 构建可以生成供模块验证器使用的 - 未发布包,但不能绕过正式安装器的包签名要求。 + 的 release 构建会失败,普通 debug 构建会跳过需要 publisher 签名的 PHP 包, + 指定单个 PHP 包构建时也会失败。这样构建目录中不会留下缺少 + `lithe-plugin-signature.json` 的伪成功包;只有提供私钥的构建才会生成可交给 + 包级验签路径的产物。 - 需要重新评估的触发条件:如果差分更新的资产数量随架构或渠道增多 逼近 900 的清理阈值,或者需要支持两个以上的更新渠道,当前基于 "30 个 build + 3 个 zip 基线"的保留规则需要重新设计。 diff --git a/Plugins/mac/README.md b/Plugins/mac/README.md index 0fba03e24..fb1784132 100644 --- a/Plugins/mac/README.md +++ b/Plugins/mac/README.md @@ -11,6 +11,7 @@ building the host API for the same configuration and architecture: ```sh LITHE_CODESIGN_IDENTITY="" \ +LITHE_PLUGIN_PACKAGE_PRIVATE_KEY="$(cat /secure/path/lithe-plugin-package-private-key.base64)" \ scripts/build-official-plugins.sh --configuration release \ --triple arm64-apple-macosx --plugin-id dev.lithe.plugin.php-support ``` @@ -20,8 +21,12 @@ Use `x86_64-apple-macosx` for Intel. Keep the resulting 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. +requires the host's signing team. A PHP package also requires +`LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`, a base64-encoded 32-byte Ed25519 private key +whose public key matches the embedded publisher key; debug builds skip PHP when +the key is absent, and a direct PHP package build fails instead of producing an +unsigned package. Keep the key in a protected file or environment variable and +never commit it. The PHP plugin archive carries a pinned Intelephense package described by `language-server.json`. Plugin Management downloads and verifies that tool as diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 31ae53b17..14f8920eb 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -203,7 +203,7 @@ SHA-256;Cargo、SwiftPM 和 Bun 使用各自的 lockfile、版本与完整性 identity stamp,任何复制阶段都禁止共享。资源清单 `jdt-maven-settings` 显式排除, 复用脚本直接拒绝该资源,不进入下载或生成物校验路由。 -PHP 插件包在 `.build///OfficialPlugins` 中独立构建,绑定宿主 API、Swift 工具链、架构和签名,通过 `LitheOfficialPluginVerifier` 验证;发布配置还要求 repository secret `LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`,由 `LithePluginPackageSigner` 对完整包生成 `lithe-plugin-signature.json`,客户端用内置公钥验证后才允许安装。无可靠 identity stamp,不跨工作树复制。插件安装后的 Intelephense 位于 +PHP 插件包在 `.build///OfficialPlugins` 中独立构建,绑定宿主 API、Swift 工具链、架构和签名,通过 `LitheOfficialPluginVerifier` 验证;发布配置还要求 repository secret `LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`,由 `LithePluginPackageSigner` 对完整包生成 `lithe-plugin-signature.json`,客户端用内置公钥验证后才允许安装。该 secret 的值是与客户端内置公钥匹配的 base64 编码 32 字节 Ed25519 私钥,可用 `gh secret set LITHE_PLUGIN_PACKAGE_PRIVATE_KEY --repo 1lck/Lithe-IDEA < /secure/path/lithe-plugin-package-private-key.base64` 配置;私钥文件和 shell 历史都不得进入仓库或日志。没有该 key 时,普通 debug 全量构建会跳过 PHP 包,指定 PHP 包 ID 的构建会失败,不会留下缺少签名文档的目录。无可靠 identity stamp,不跨工作树复制。插件安装后的 Intelephense 位于 `/Lithe/Plugins//versions//PhpSupport.bundle/Contents/Resources/LanguageServers/php`,由插件版本目录拥有,重装、回滚和卸载随插件一起处理,不是工作树构建缓存。PHPUnit 测试夹具的 `shared/fixtures/phpunit-project/vendor` 也由当前工作树独立安装。以上项目在资源清单 `excludedResources` 中明确排除,复用脚本会拒绝显式复制请求。 如果后续新增可复用资源,必须同步更新注册表、校验器、脚本测试和本节说明。 diff --git a/scripts/build-official-plugins.sh b/scripts/build-official-plugins.sh index 6b20a8b4a..f50e542a5 100755 --- a/scripts/build-official-plugins.sh +++ b/scripts/build-official-plugins.sh @@ -76,6 +76,15 @@ for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do if [[ "$BUNDLED_ONLY" == true ]] && ! node "$ROOT_DIR/scripts/official-plugin-distribution.mjs" "$package_id"; then continue fi + signature_requirement=$(/usr/bin/plutil -extract vendor.signatureRequirement raw "$manifest") + if [[ "$signature_requirement" == "publisherPackage" && -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-signed debug plugin package $package_id; set LITHE_PLUGIN_PACKAGE_PRIVATE_KEY to build it" + continue + fi matched=$((matched + 1)) module_suffix="${plugin_source:t}" source_dir="$plugin_source/Sources/Lithe${module_suffix}Module" @@ -86,7 +95,6 @@ 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" @@ -126,14 +134,6 @@ for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do /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[@]}" \ diff --git a/scripts/verify-official-plugins.sh b/scripts/verify-official-plugins.sh index 8087e744d..7becce55f 100755 --- a/scripts/verify-official-plugins.sh +++ b/scripts/verify-official-plugins.sh @@ -25,6 +25,7 @@ PLUGIN_ROOT=$(scripts/build-official-plugins.sh \ --configuration debug \ --triple "$TRIPLE") plugins=("$PLUGIN_ROOT"/*(/N)) +package_signer_binary="" for plugin in "${plugins[@]}"; do swift run "${SWIFT_BUILD_ARGS[@]}" --skip-build LitheOfficialPluginVerifier "$plugin" if [[ "$plugin:t" == "dev.lithe.plugin.php-support" ]]; then @@ -38,6 +39,16 @@ for plugin in "${plugins[@]}"; do exit 1 } /usr/bin/codesign --verify --deep --strict "$plugin/PhpSupport.bundle" + if [[ -z "$package_signer_binary" ]]; then + swift build "${SWIFT_BUILD_ARGS[@]}" --product LithePluginPackageSigner + signer_bin_dir=$(swift build "${SWIFT_BUILD_ARGS[@]}" --show-bin-path) + package_signer_binary="$signer_bin_dir/LithePluginPackageSigner" + [[ -x "$package_signer_binary" ]] || { + print -u2 -- "Plugin package signer was not built: $package_signer_binary" + exit 1 + } + fi + "$package_signer_binary" --verify "$plugin" fi done -print "Verified ${#plugins[@]} released official native plugin package(s)" +print "Verified ${#plugins[@]} official native plugin package(s)" From 1906da846a848ea40f7d2efafbef5facb240b8fa Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 2 Oct 2026 19:18:19 +0800 Subject: [PATCH 6/9] fix(macos): keep plugin signer status off stdout --- macos/Tools/LithePluginPackageSigner/main.swift | 10 ++++++++-- scripts/verify-official-plugins.sh | 4 ++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/macos/Tools/LithePluginPackageSigner/main.swift b/macos/Tools/LithePluginPackageSigner/main.swift index 035dd9d26..a5002e17f 100644 --- a/macos/Tools/LithePluginPackageSigner/main.swift +++ b/macos/Tools/LithePluginPackageSigner/main.swift @@ -29,7 +29,7 @@ struct LithePluginPackageSigner { document: document, publicKey: publicKey ) - print("Verified publisher signature for \(manifest.pluginID) \(manifest.pluginVersion)") + writeStatus("Verified publisher signature for \(manifest.pluginID) \(manifest.pluginVersion)") return } @@ -54,7 +54,13 @@ struct LithePluginPackageSigner { privateKey: privateKey ) try PluginPackageSignature.write(document, to: packageURL) - print("Signed publisher package \(manifest.pluginID) \(manifest.pluginVersion)") + writeStatus("Signed publisher package \(manifest.pluginID) \(manifest.pluginVersion)") + } + + /// Keep stdout available for machine-readable command results. + /// Build scripts capture stdout as the package path, so status messages belong on stderr. + private static func writeStatus(_ message: String) { + FileHandle.standardError.write(Data((message + "\n").utf8)) } private static func loadManifest(from packageURL: URL) throws -> (pluginID: String, pluginVersion: String) { diff --git a/scripts/verify-official-plugins.sh b/scripts/verify-official-plugins.sh index 7becce55f..323dbcdc6 100755 --- a/scripts/verify-official-plugins.sh +++ b/scripts/verify-official-plugins.sh @@ -24,6 +24,10 @@ swift build "${SWIFT_BUILD_ARGS[@]}" --product LitheOfficialPluginVerifier PLUGIN_ROOT=$(scripts/build-official-plugins.sh \ --configuration debug \ --triple "$TRIPLE") +[[ -d "$PLUGIN_ROOT" ]] || { + print -u2 -- "Official plugin build returned an invalid package root: $PLUGIN_ROOT" + exit 1 +} plugins=("$PLUGIN_ROOT"/*(/N)) package_signer_binary="" for plugin in "${plugins[@]}"; do From 53ffe9dd8d2342d4de5534a0b7adbf929095d740 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 2 Oct 2026 19:21:06 +0800 Subject: [PATCH 7/9] test(macos): assert signed PHP package output --- scripts/verify-official-plugins.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/verify-official-plugins.sh b/scripts/verify-official-plugins.sh index 323dbcdc6..e3bf467c4 100755 --- a/scripts/verify-official-plugins.sh +++ b/scripts/verify-official-plugins.sh @@ -28,6 +28,12 @@ PLUGIN_ROOT=$(scripts/build-official-plugins.sh \ print -u2 -- "Official plugin build returned an invalid package root: $PLUGIN_ROOT" exit 1 } +if [[ -n "${LITHE_PLUGIN_PACKAGE_PRIVATE_KEY:-}" ]]; then + [[ -d "$PLUGIN_ROOT/dev.lithe.plugin.php-support" ]] || { + print -u2 -- "Publisher key was provided but the PHP plugin package was not built" + exit 1 + } +fi plugins=("$PLUGIN_ROOT"/*(/N)) package_signer_binary="" for plugin in "${plugins[@]}"; do From b9f2c7279b5ecb0dff89a1d39c2663f633ba4126 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 2 Oct 2026 20:05:34 +0800 Subject: [PATCH 8/9] fix(macos): keep plugin build logs off stdout --- scripts/build-official-plugins.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build-official-plugins.sh b/scripts/build-official-plugins.sh index f50e542a5..6a10a70a1 100755 --- a/scripts/build-official-plugins.sh +++ b/scripts/build-official-plugins.sh @@ -137,7 +137,7 @@ for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do if [[ -z "$signer_binary" ]]; then swift build \ "${SWIFT_LAYOUT_ARGS[@]}" \ - --product LithePluginPackageSigner + --product LithePluginPackageSigner >&2 signer_bin_dir=$(swift build \ "${SWIFT_LAYOUT_ARGS[@]}" \ --show-bin-path) From 3220df0c7f1d8de1877c024b7bd8970f5f74d28d Mon Sep 17 00:00:00 2001 From: Xiaoyumuxi <3075514079@qq.com> Date: Sat, 3 Oct 2026 13:30:31 +0800 Subject: [PATCH 9/9] docs(matrix): record PHP publisher trust verification --- .../features/php-optional-plugin.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/shared/platform-feature-matrix/features/php-optional-plugin.json b/shared/platform-feature-matrix/features/php-optional-plugin.json index 3543dcb44..27b9e6899 100644 --- a/shared/platform-feature-matrix/features/php-optional-plugin.json +++ b/shared/platform-feature-matrix/features/php-optional-plugin.json @@ -10,7 +10,9 @@ "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" + ".github/workflows/release-macos.yml", + "macos/Sources/LithePluginPackageSigning/PluginPackageSignature.swift", + "macos/Tests/LitheTests/PluginPackageStoreTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -27,5 +29,5 @@ "verificationStatus": "pending" }, "owner": "PHP Support", - "verification": "macOS 在干净安装上打开插件管理,确认可从当前发行渠道下载并安装 PHP、失败时可从磁盘导入;安装后重启,在 LSP 界面只控制当前项目开关和状态,验证重装、卸载均在重启后生效且用户工具保留。Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。" + "verification": "macOS 在干净安装上打开插件管理,确认可从当前发行渠道下载并安装 PHP、失败时可从磁盘导入;安装后重启,在 LSP 界面只控制当前项目开关和状态,验证重装、卸载均在重启后生效且用户工具保留。对官方 PHP 包将 plugin.json 的 signatureRequirement 从 publisherPackage 篡改为 sameTeamAsHost,确认 MacPluginPackageStore 在调用包 verifier 前按宿主 OfficialPluginCatalog 拒绝策略降级;删除或篡改 lithe-plugin-signature.json、manifest、版本或任意签名覆盖文件时也必须拒绝。Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。" }