From 00ab21e22bf70ed5ce0a2402ec74af705ca68ba6 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Sun, 25 Oct 2026 13:10:43 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(plugins):=20=E6=94=AF=E6=8C=81=20Java?= =?UTF-8?q?=20=E7=AD=BE=E5=90=8D=E5=AE=89=E8=A3=85=E4=B8=8E=E5=8F=8C?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E7=8B=AC=E7=AB=8B=E5=8F=91=E5=B8=83=E5=9F=BA?= =?UTF-8?q?=E7=A1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...09-30-lsp-plugin-build-and-distribution.md | 2 +- ...a-language-server-as-declarative-plugin.md | 4 +- ...-25-java-plugin-distribution-foundation.md | 90 +++++++ ...4-java-support-independent-distribution.md | 3 + .../workflows/release-official-plugins.yml | 127 +++++----- Plugins/mac/Official/JavaSupport/plugin.json | 7 +- Plugins/mac/README.md | 13 +- Plugins/marketplace/official-plugins.json | 22 +- docs/ci-builds.md | 29 +++ .../MacOS/Plugins/MacPluginPackageStore.swift | 48 +++- .../Plugins/DeclarativePluginContracts.swift | 4 +- .../Catalog/BuiltInModuleCatalog.swift | 6 +- .../LitheModuleAPI/Plugins/PluginTypes.swift | 8 +- .../OfficialPluginVerifier.swift | 5 +- .../PluginBundledPublisherTrustTests.swift | 166 +++++++++++++ .../LitheTests/ProjectGenerationTests.swift | 5 + rust/lithe-core/src/plugins/capabilities.rs | 4 + rust/lithe-core/src/plugins/declarative.rs | 20 ++ .../src/tests/declarative_plugins.rs | 55 +++++ .../src/tests/marketplace_plugins.rs | 42 ++++ .../src/tests/project_generators.rs | 2 +- scripts/build-official-plugins.sh | 12 +- scripts/official-plugin-distribution.mjs | 4 +- scripts/official-plugin-releases.mjs | 220 ++++++++++-------- scripts/official-plugin-releases.test.mjs | 145 ++++++------ scripts/reuse-worktree-resources.mjs | 2 + scripts/test-reuse-worktree-resources.mjs | 10 + scripts/worktree-resources.json | 12 +- shared/contracts/plugins/v1/README.md | 15 ++ .../v1/fixtures/publisher-bundled.plugin.json | 22 ++ .../plugins/v1/plugin-manifest.schema.json | 7 +- .../features/java-support-plugin.json | 10 +- 32 files changed, 866 insertions(+), 255 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-25-java-plugin-distribution-foundation.md create mode 100644 macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift create mode 100644 shared/contracts/plugins/v1/fixtures/publisher-bundled.plugin.json diff --git a/.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md b/.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md index e9163c1cad..b10c0f7a21 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md +++ b/.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md @@ -35,7 +35,7 @@ PHP Support 已经验证了独立插件包、固定 Intelephense 版本和插件 插件包不编译、不链接任何 Swift 代码,也不做 codesign,因此与架构和宿主构建无关。 -唯一的例外是自带运行时的 Java Support:它的包里有每个架构各一份的 JDK,因此与架构有关,并且只随应用发行、由应用的代码签名覆盖。它的组装步骤在插件自己的目录里,见 [Java 语言服务器是一个声明式插件](2026-10-13-java-language-server-as-declarative-plugin.md)。 +唯一的例外是自带运行时的 Java Support:它的包里有每个架构各一份的 JDK,因此与架构有关,当前仍随应用发行,内置副本由应用的代码签名覆盖;独立安装副本要求完整包的发布者签名。双架构发布输入已准备,自动上架仍暂停,见[按来源校验与独立发布基础](2026-10-25-java-plugin-distribution-foundation.md)。它的组装步骤在插件自己的目录里,见 [Java 语言服务器是一个声明式插件](2026-10-13-java-language-server-as-declarative-plugin.md)。 下载和组装逻辑放在插件专属的准备脚本里(PHP 迁出前是 `prepare-php-language-server.sh`,已随迁移删除)。后续插件可以复用相同的构建阶段和校验结构,但不能把某个语言的路径、版本或下载地址硬编码到宿主应用中。 diff --git a/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md b/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md index 657e05ee2b..a8465bbfad 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md +++ b/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md @@ -106,7 +106,7 @@ dev.lithe.plugin.java-support/ ### 5. 随应用发行、默认启用、可卸载 -- **随应用发行**:Java 是主场景,首次启动必须离线可用,所以包在 `Lithe.app/Contents/Resources/OfficialPlugins/` 下,信任方式是 `sameTeamAsHost`(由应用自身的代码签名封住)。 +- **随应用发行**:Java 是主场景,首次启动必须离线可用,所以包在 `Lithe.app/Contents/Resources/OfficialPlugins/` 下,清单主签名要求为 `publisherPackage`,内置副本通过 `bundledSignatureRequirement: sameTeamAsHost` 依靠应用自身的代码签名;只有真实位于宿主 bundle 内且来源为 bundled 的副本能用这个例外。按来源校验与独立发布基础见[后续决策](2026-10-25-java-plugin-distribution-foundation.md)。 - **默认启用**:随应用发行的包替代的是内置功能,它的模块默认开启;下载安装的插件,模块清单默认值仍是关闭,但用户在确认窗口里确认的首次安装(插件市场、社区源、磁盘包)会把它的模块记为启用,重启后直接生效,与 IntelliJ IDEA 安装即启用一致;更新和重装不改用户已有的启用选择。否则构建插件装好后 Build 工具窗口不出现,用户会以为界面没有注入。 - **卸载**:app bundle 运行时只读,包本身删不掉。卸载记录在用户目录的 `/Lithe/Plugins/.removed-bundled-plugins.json`,宿主重启后不再注册这个插件,并在那次启动时删除它的语言服务器状态(卸载后到重启前,语言服务器仍在运行并写入该目录,所以不在点击时删除;重启前恢复则不会丢失索引)。插件管理里仍然列出它并提供“恢复”。 - **卸载时 JDT 状态的清理**:JDT LS 的工作区索引(`/Lithe/language-servers/jdtls/`)和 Equinox 配置区(`jdtls-configuration/`)在宿主缓存里,不在插件自己的 `PluginState//` 下,插件包管理器删不到。所以由 `jdt` 集成自己清理:启动时 `pluginsDidLoad` 拿到扫描结果,如果没有任何“未卸载”的已安装插件声明 `jdt` 集成,就在后台删除这两个目录(不碰同目录下其他语言服务器的缓存)。扫描本身失败(拿不到已安装列表)、或插件已安装但启动时被拒绝,都不算卸载,状态保留。这样宿主既不用写死 Java Support 的插件 ID,也不需要把缓存搬进插件状态目录。 @@ -170,7 +170,7 @@ dev.lithe.plugin.java-support/ - Rust 引擎里仍按提供方 ID `java` 启用 JDT 适配;Java 的运行、测试和调试仍是宿主内置实现,尚未成为扩展点,由插件通过 `hostIntegration.capabilities` 声明后启用。 - Windows 仍把 JDT LS 和 JDK 放在安装目录里,没有接入这份契约。 -尚未完成、需要单独提案的部分:把 `jdt://` 虚拟文档等协议扩展改成清单里的声明式特性;Debug、Run/Test 扩展点;Java Support 改为从市场下载;Windows 接入。 +尚未完成、需要继续推进的部分:把 `jdt://` 虚拟文档等协议扩展改成清单里的声明式特性;Debug、Run/Test 扩展点;Java Support 的正式市场下载与迁移;Windows 接入。独立分发已具备来源签名校验和双架构发布基础,但自动发布暂停,计划见[独立分发提案](../../proposed/architecture/2026-10-24-java-support-independent-distribution.md)。 ## 验证 diff --git a/.agents/notes/implemented/architecture/2026-10-25-java-plugin-distribution-foundation.md b/.agents/notes/implemented/architecture/2026-10-25-java-plugin-distribution-foundation.md new file mode 100644 index 0000000000..d3c0ae6e56 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-25-java-plugin-distribution-foundation.md @@ -0,0 +1,90 @@ +# Agent 笔记:Java 插件按来源校验与独立发布基础 + +状态:已实现 + +## 先说结论 + +Java Support 的同一份清单现在区分两种来源:随应用发行的副本可以依靠宿主签名,用户下载安装的副本必须有 Lithe 发布者签名。两种来源使用同一插件 ID,经过校验的用户安装版本替代内置版本,不重复登记 Java 语言服务。官方发布工具能记录 ARM64、Intel 两个归档及各自哈希,并从发布标签读取对应 API 和能力声明;自动上架仍暂停,真实发布包和用户迁移按[独立分发提案](../../proposed/architecture/2026-10-24-java-support-independent-distribution.md)继续验收。 + +## 问题 + +Java 已经是声明式插件,但 `sameTeamAsHost` 原来只接受应用签名封装内的文件。直接把目录放到用户插件目录会被拒绝;直接把策略改成 `publisherPackage` 又会要求普通应用构建持有插件发布私钥。 + +旧发布脚本把索引 API 写死为 1,且每版只有一个 ZIP。Java 使用 API 3,又携带不同架构的 JDK,必须保留真实 `requires`(宿主必须支持的能力版本)和两个归档。历史版本重试上架时,还不能拿当前清单描述旧归档。 + +## 决策 + +### 同一清单,来源决定是否使用宿主签名 + +新增通用能力 `packageTrust: 1`,只在清单声明 `vendor.bundledSignatureRequirement` 时使用。Rust Core 只允许官方命名空间、API 3、主签名为 `publisherPackage`,且内置策略只能是 `sameTeamAsHost`。 + +macOS 官方目录固定主签名和内置策略。包管理器先校验清单与官方目录一致,再根据解析后的实际路径和安装来源验证:只有来源为 `bundled` 且文件确实位于宿主 app 内的副本可以依靠宿主签名。外部目录伪造来源无效;安装记录、回滚版本和磁盘导入同样不能借用该例外。任何来源只要带了签名文档,就必须验证它,不能因副本在 app 内而忽略坏签名。 + +旧宿主没有 `packageTrust`,会在市场过滤和清单校验时拒绝这一版。Windows 本轮不声明该能力,后续实现相同来源边界之后才允许安装,不将 Core 能校验等同于宿主能执行。 + +正确做法:下载新版后校验完整文件树的发布者签名,再把完整版本放到平台应用数据目录;重启读取同一 ID 时只选择一个生效来源。 + +不要这样做:外部目录沿用 `sameTeamAsHost`,或声称 `bundled` 就省略签名。也不能把官方 ID 的策略改成同时接受任意一种签名。 + +### 构建显式选择内置副本 + +应用打包继续使用 `--bundled-only`。只有显式走这条路径且清单声明内置策略时,构建脚本才省略发布者签名,最终由应用签名封住资源。普通单插件 release 构建要求发布者私钥;debug 的 `--development-unsigned` 只用于本地开发,正式宿主拒绝它。 + +JDT LS、JDK、Lombok、Debug 和 Test 扩展保持当前锁定版本。资源先组装,原生签名等会改字节的步骤完成后才生成发布者签名,最终归档解压后再验证。运行时启动继续复用现有 `jdt` 集成;安装检查新增对运行时启动的计划校验,拒绝缺失目标架构的 JDK、包内入口和相关固定资源,不运行 JDK、不创建验证状态目录。 + +### 发布按标签固定全部输入 + +`official-plugins.json` 登记 Java 两种架构的归档和上游构建输入,`releaseEnabled: false` 保持暂停自动发布。先有包含新能力的宿主版本,再通过独立改动开放上架;本次不生成线上索引或发布标签。 + +发布计划携带清单的真实 API 和 `requires`,组装全部架构后再创建同一个 Release。索引同步从对应 Git tag 读取当时的发布配置和清单,下载所有归档并计算 SHA-256 后一次写入该版本;任一归档缺失或哈希无效都不写部分版本。已发布插件的目录或登记的上游输入发生变化,需要提高插件版本。 + +### 资源所有权 + +- `dist/packages//` 是发布 job 的组装输出,`dist/verify//` 是归档验证副本,ZIP 和校验文件属于同一次发布。job 结束回收,不跨工作树复制。 +- `.artifacts/java-plugin-distribution/` 是本地验证输出,没有可转移的版本与工具链身份,验证者负责删除不用的临时副本;资源复用脚本明确拒绝。 +- JDT/JDK 下载缓存仍只能经原有资源校验路由复用。每个工作树从固定上游输入重新组装插件,不共享已签名包或用户安装目录。 +- 用户安装的插件版本是只读输入;索引、Equinox 配置、日志和锁仍由平台缓存和状态适配器持有。运行时不改 app bundle、插件安装资源或 Windows 发行目录,保持代码签名和 Sparkle 增量更新源基线。 + +## 考虑过的备选方案 + +- 对 Java 官方 ID 全局放宽签名:实现简单,但外部文件会获得内置包才有的信任,拒绝。 +- 应用构建也要求插件发布私钥:统一使用包签名,但普通开发和应用 PR 构建不应该依赖发布私钥,拒绝。 +- 生成两份不同来源清单:减少清单字段,但会造成同一插件版本有不同清单和能力要求,使用同一声明按来源验证。 +- 为旧宿主新增最低应用版本字段:当前能力协商已能拒绝未知语义,不额外引入另一套版本比较。 +- 索引重试使用最新清单:容易实现,但旧归档会被描述为具有后来新增的能力,改为从不可变标签读取。 + +## 后果 + +收益是应用打包仍能独立完成,外部安装具有完整签名检查,Java 资源可以按架构独立发布。代价是首次下载版要求新宿主能力,签名策略不能只看一个枚举,还要核对官方目录、来源和真实路径。 + +这一步交付分发基础。主包仍含 Java,索引未上架,老用户引导、启用选择迁移、正式发布签名包和完整产品下载验收尚未由本次改动证明;提案保持 `proposed`。 + +## 验证 + +- `./scripts/verify-rust-core-comments.sh`、`./scripts/verify-shared-contracts.sh`:契约、注释和共享输入一致。 +- Rust 的 `declarative_plugins` 与 `marketplace_plugins`:来源策略必须声明能力、非法组合拒绝、旧宿主在下载前拒绝。 +- `PluginBundledPublisherTrustTests`:真实 Core、生产包管理器及注入测试公钥验证签名安装、单一来源、包篡改、伪造来源和缺失运行时,比较安装资源内容。 +- `node --test scripts/official-plugin-releases.test.mjs`:双架构索引、部分归档拒绝、历史标签元数据以及上游输入变更约束。 +- `node scripts/test-reuse-worktree-resources.mjs`、`./scripts/verify-runtime-bundle-immutability.sh`:生成资源拒绝复用、发行目录只读边界。 +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` 和 macOS 计时入口:相关测试必须有单项时长与 HTML/JUnit 结果。 +- `.github/workflows/release-official-plugins.yml` 用 actionlint 校验;本地组装两种架构资源,验证 JDK 原生签名和发布归档的完整性。 + +上述验证不能替代正式发布者私钥签名、真实市场下载、Intel 真机及全部 Java 产品工作流。上线前继续按独立分发提案的退出条件执行,不能靠测试公钥通过就开放发布。 + +## 适用范围 + +- `Plugins/mac/Official/JavaSupport/` +- `Plugins/marketplace/official-plugins.json` +- `shared/contracts/plugins/v1/` +- `rust/lithe-core/src/plugins/` +- `macos/Sources/LitheModuleAPI/` +- `macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift` +- `macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift` +- `macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift` +- `macos/Tests/LitheOfficialPluginVerifier/OfficialPluginVerifier.swift` +- `scripts/build-official-plugins.sh` +- `scripts/official-plugin-releases.mjs` +- `.github/workflows/release-official-plugins.yml` +- `scripts/worktree-resources.json` +- `scripts/reuse-worktree-resources.mjs` +- `docs/ci-builds.md` diff --git a/.agents/notes/proposed/architecture/2026-10-24-java-support-independent-distribution.md b/.agents/notes/proposed/architecture/2026-10-24-java-support-independent-distribution.md index f0e75919d6..0fd7c3d59f 100644 --- a/.agents/notes/proposed/architecture/2026-10-24-java-support-independent-distribution.md +++ b/.agents/notes/proposed/architecture/2026-10-24-java-support-independent-distribution.md @@ -121,6 +121,9 @@ Windows 插件包使用自己的原生运行时、入口和存储适配器,复 ### 八、分期与退出条件 +第一阶段的实现边界见[Java 插件按来源校验与独立发布基础](../../implemented/architecture/2026-10-25-java-plugin-distribution-foundation.md)。该实现登记双架构发布输入但保留 `releaseEnabled: false`,尚未向用户公开下载;过渡宿主发行、真实发布包验收和后续迁移仍按本提案推进。 + + | 阶段 | 交付内容 | 进入下一阶段前必须证明 | | --- | --- | --- | | 0:确认基线 | 盘点两种 macOS 架构的实际资源、压缩体积、JDK 消费者和离线行为;记录上游版本与许可证 | 明确本轮只改变分发;列出需保留的用户工作流和资源消费者 | diff --git a/.github/workflows/release-official-plugins.yml b/.github/workflows/release-official-plugins.yml index ce3cfaa921..14b3e4ee8e 100644 --- a/.github/workflows/release-official-plugins.yml +++ b/.github/workflows/release-official-plugins.yml @@ -14,12 +14,25 @@ on: - "Plugins/mac/Official/**" - "Plugins/marketplace/**" - "scripts/official-plugin-releases.mjs" + - "scripts/official-plugin-releases.test.mjs" + - "scripts/build-official-plugins.sh" + - "scripts/prepare-jdtls.sh" + - "scripts/prepare-jdk.sh" + - "third_party/jdtls/manifest.json" + - "third_party/jdk/manifest.json" + - ".github/workflows/release-official-plugins.yml" push: branches: - preview paths: - "Plugins/mac/Official/**" - "Plugins/marketplace/official-plugins.json" + - "scripts/build-official-plugins.sh" + - "scripts/prepare-jdtls.sh" + - "scripts/prepare-jdk.sh" + - "third_party/jdtls/manifest.json" + - "third_party/jdk/manifest.json" + - ".github/workflows/release-official-plugins.yml" workflow_dispatch: permissions: @@ -56,7 +69,7 @@ jobs: release: name: Release ${{ matrix.release.tag }} needs: plan - if: github.repository == 'Lithe-IDEA/Lithe-IDEA' && github.event_name != 'pull_request' && github.ref == 'refs/heads/preview' && needs.plan.outputs.releases != '[]' + if: github.repository == '1lck/Lithe-IDEA' && github.event_name != 'pull_request' && github.ref == 'refs/heads/preview' && needs.plan.outputs.releases != '[]' runs-on: macos-26 timeout-minutes: 30 permissions: @@ -78,54 +91,61 @@ jobs: - name: Set up Swift 6.3.3 and Xcode uses: ./.github/actions/setup-macos-toolchain - - name: Build and sign the plugin package + - name: Build, sign, and verify every architecture env: LITHE_PLUGIN_PACKAGE_PRIVATE_KEY: ${{ secrets.LITHE_PLUGIN_PACKAGE_PRIVATE_KEY }} - PLUGIN_ID: ${{ matrix.release.id }} - ASSET_NAME: ${{ matrix.release.assetName }} + RELEASE_JSON: ${{ toJSON(matrix.release) }} run: | + set -euo pipefail test -n "$LITHE_PLUGIN_PACKAGE_PRIVATE_KEY" || { echo "Configure LITHE_PLUGIN_PACKAGE_PRIVATE_KEY before releasing plugins"; exit 1; } - # Declarative packages contain no native code, so one archive serves - # every macOS architecture listed in official-plugins.json. - plugin_root=$(scripts/build-official-plugins.sh \ - --configuration release \ - --triple arm64-apple-macosx \ - --plugin-id "$PLUGIN_ID" \ - --output dist/official-plugins) - ditto -c -k --sequesterRsrc --keepParent "$plugin_root/$PLUGIN_ID" "dist/$ASSET_NAME" - shasum -a 256 "dist/$ASSET_NAME" | cut -d' ' -f1 > "dist/$ASSET_NAME.sha256" - - - name: Verify the archived package - env: - PLUGIN_ID: ${{ matrix.release.id }} - VERSION: ${{ matrix.release.version }} - ASSET_NAME: ${{ matrix.release.assetName }} - run: | - # The build script already verified the publisher signature; check - # that the archive users download holds exactly that package. - mkdir -p dist/verify - ditto -x -k "dist/$ASSET_NAME" dist/verify - manifest="dist/verify/$PLUGIN_ID/plugin.json" - test "$(plutil -extract id raw "$manifest")" = "$PLUGIN_ID" - test "$(plutil -extract version raw "$manifest")" = "$VERSION" - test -f "dist/verify/$PLUGIN_ID/lithe-plugin-signature.json" - - - name: Publish the plugin release + id=$(jq -r .id <<<"$RELEASE_JSON") + version=$(jq -r .version <<<"$RELEASE_JSON") + swift build --product LitheOfficialPluginVerifier + swift_bin=$(swift build --show-bin-path) + jq -c '.assets[]' <<<"$RELEASE_JSON" | while read -r asset; do + name=$(jq -r .assetName <<<"$asset") + arch=$(jq -r '.arch // "arm64"' <<<"$asset") + plugin_root=$(scripts/build-official-plugins.sh \ + --configuration release --triple arm64-apple-macosx --arch "$arch" \ + --plugin-id "$id" --output "dist/packages/$arch") + ditto -c -k --keepParent "$plugin_root/$id" "dist/$name" + verify="dist/verify/$arch" + mkdir -p "$verify" + ditto -x -k "dist/$name" "$verify" + manifest="$verify/$id/plugin.json" + test "$(plutil -extract id raw "$manifest")" = "$id" + test "$(plutil -extract version raw "$manifest")" = "$version" + "$swift_bin/LitheOfficialPluginVerifier" "$verify/$id" + signer="$(swift build --configuration release --triple arm64-apple-macosx --show-bin-path)/LithePluginPackageSigner" + "$signer" --verify "$verify/$id" + # Preserve the pinned upstream runtime's native signatures. The + # publisher signature alone does not prove macOS can launch it. + runtime="$verify/$id/runtime/$arch" + if [ -d "$runtime" ]; then + lipo "$runtime/bin/java" -verify_arch "$arch" + codesign --verify --strict "$runtime/bin/java" + codesign --verify --strict "$runtime/lib/server/libjvm.dylib" + fi + shasum -a 256 "dist/$name" | cut -d' ' -f1 > "dist/$name.sha256" + done + + - name: Publish all architectures in one release env: GH_TOKEN: ${{ github.token }} - TAG: ${{ matrix.release.tag }} - ASSET_NAME: ${{ matrix.release.assetName }} - PLUGIN_ID: ${{ matrix.release.id }} - VERSION: ${{ matrix.release.version }} + RELEASE_JSON: ${{ toJSON(matrix.release) }} run: | - # Plugin releases must never take the repository's "Latest" slot: - # app updates resolve releases/latest/download/appcast-*.xml, which - # only the Lithe app releases publish. - gh release create "$TAG" "dist/$ASSET_NAME" \ - --latest=false \ - --target "$GITHUB_SHA" \ - --title "$PLUGIN_ID $VERSION" \ - --notes "Official Lithe plugin $PLUGIN_ID $VERSION. SHA-256: $(cat "dist/$ASSET_NAME.sha256")" + set -euo pipefail + tag=$(jq -r .tag <<<"$RELEASE_JSON") + id=$(jq -r .id <<<"$RELEASE_JSON") + version=$(jq -r .version <<<"$RELEASE_JSON") + assets=() + while read -r asset; do + assets+=("dist/$asset" "dist/$asset.sha256") + done < <(jq -r '.assets[].assetName' <<<"$RELEASE_JSON") + printf 'Official Lithe plugin %s %s. Each architecture has its own archive and SHA-256 file.\n' "$id" "$version" > dist/release-notes.md + # App update checks own the repository's Latest slot. + gh release create "$tag" "${assets[@]}" --latest=false --target "$GITHUB_SHA" \ + --title "$id $version" --notes-file dist/release-notes.md sync-index: name: List released plugins in the official index @@ -135,7 +155,7 @@ jobs: # a listing that failed earlier is retried. if: >- !cancelled() - && github.repository == 'Lithe-IDEA/Lithe-IDEA' + && github.repository == '1lck/Lithe-IDEA' && github.event_name != 'pull_request' && github.ref == 'refs/heads/preview' && needs.plan.result == 'success' @@ -167,24 +187,27 @@ jobs: id=$(jq -r .id <<<"$release") version=$(jq -r .version <<<"$release") tag=$(jq -r .tag <<<"$release") - asset=$(jq -r .assetName <<<"$release") branch="automation/plugin-index-$tag" if [ "$(gh pr list --head "$branch" --state open --json number --jq length)" != "0" ]; then echo "$tag already has an open index pull request" continue fi - # The checksum comes from the asset users will download, not from - # a local build, so a rerun lists exactly what was published. + # A retry reads the tagged configuration and manifest, and hashes + # every published archive before listing this version atomically. rm -rf dist && mkdir -p dist - gh release download "$tag" --pattern "$asset" --dir dist - sha=$(sha256sum "dist/$asset" | cut -d' ' -f1) + checksums='{}' + while read -r asset; do + gh release download "$tag" --pattern "$asset" --dir dist + sha=$(sha256sum "dist/$asset" | cut -d' ' -f1) + checksums=$(jq -c --arg name "$asset" --arg sha "$sha" '. + {($name): $sha}' <<<"$checksums") + done < <(jq -r '.assets[].assetName' <<<"$release") released=$(gh release view "$tag" --json publishedAt --jq '.publishedAt[0:10]') git switch --force-create "$branch" origin/preview - node scripts/official-plugin-releases.mjs update-index "$id" "$version" "$sha" "$released" + node scripts/official-plugin-releases.mjs update-index "$id" "$version" "$checksums" "$released" git add Plugins/marketplace/index.json git commit -m "chore(plugins): 将 $id $version 加入官方插件索引" git push --force origin "$branch" - gh pr create --base preview --head "$branch" \ - --title "chore(plugins): 官方插件索引加入 $id $version" \ - --body "插件发布流程已发布 \`$tag\`(资产 \`$asset\`,SHA-256 \`$sha\`)。本 PR 只把该版本的下载地址和 SHA-256 写入 \`Plugins/marketplace/index.json\`,合并后用户在插件市场刷新即可看到新版本。" + printf '发布流程已发布 %s。本 PR 将该版本全部架构的下载地址、实际归档 SHA-256、API 等级和能力要求写入官方索引。\n' "$tag" > dist/index-pr.md + gh pr create --draft --base preview --head "$branch" \ + --title "chore(plugins): 官方插件索引加入 $id $version" --body-file dist/index-pr.md done diff --git a/Plugins/mac/Official/JavaSupport/plugin.json b/Plugins/mac/Official/JavaSupport/plugin.json index a82aedeeb0..f2740960dd 100644 --- a/Plugins/mac/Official/JavaSupport/plugin.json +++ b/Plugins/mac/Official/JavaSupport/plugin.json @@ -2,13 +2,14 @@ "schemaVersion": 2, "id": "dev.lithe.plugin.java-support", "displayName": "Java Support", - "version": "1.0.0", + "version": "1.1.0", "pluginAPIVersion": 3, - "requires": {"languages": 1, "languageServers": 1, "serverProcess": 1, "settings": 2, "projectGenerators": 1}, + "requires": {"languages": 1, "languageServers": 1, "serverProcess": 1, "settings": 2, "projectGenerators": 1, "packageTrust": 1}, "vendor": { "id": "dev.lithe", "displayName": "Lithe", - "signatureRequirement": "sameTeamAsHost" + "signatureRequirement": "publisherPackage", + "bundledSignatureRequirement": "sameTeamAsHost" }, "permissions": ["languageServer.launch", "project.generate"], "activationEvents": ["onLanguage:java"], diff --git a/Plugins/mac/README.md b/Plugins/mac/README.md index 1063141b25..a619fe4e70 100644 --- a/Plugins/mac/README.md +++ b/Plugins/mac/README.md @@ -61,9 +61,10 @@ its manifest fixture, and the Core tests; the SDK's sample provider ## Downloadable official plugins -No downloadable official plugin is configured right now: -`Plugins/marketplace/official-plugins.json` and `Plugins/marketplace/index.json` -are empty. The release flow (`scripts/build-official-plugins.sh`, +Java Support now has a two-architecture release configuration, with automatic +publication disabled (`releaseEnabled: false`) until the host trust capability +and migration are released. No downloadable official plugin is listed yet: +`Plugins/marketplace/index.json` is empty. The release flow (`scripts/build-official-plugins.sh`, `scripts/official-plugin-releases.mjs`, and `.github/workflows/release-official-plugins.yml`) is kept for future official plugins. A new one gets its own directory under `Official/`, its own tag series @@ -98,3 +99,9 @@ rejected for release plugin builds. A package that does contain a signature document is always verified, whichever way it was installed. Plugins never delete user tools or project files. + +Java Support's manifest now requires `packageTrust: 1`: user-installed copies +require a publisher signature, while the explicitly pinned bundled copy can +rely on the host application's seal. `--bundled-only` selects that application +build; an ordinary standalone release build must carry the publisher signature. +Existing hosts without this capability refuse the new version before download. diff --git a/Plugins/marketplace/official-plugins.json b/Plugins/marketplace/official-plugins.json index 25d9ad23f3..2eebe59bcb 100644 --- a/Plugins/marketplace/official-plugins.json +++ b/Plugins/marketplace/official-plugins.json @@ -1,5 +1,25 @@ { "schemaVersion": 1, "description": "Official plugins released through their own tag series and listed in index.json. A plugin is released when its packageDirectory changes and its plugin.json version has no tag yet; changing the directory without a new version fails CI.", - "plugins": [] + "plugins": [ + { + "id": "dev.lithe.plugin.java-support", + "releaseEnabled": false, + "tagPrefix": "plugin-java-support-v", + "packageDirectory": "Plugins/mac/Official/JavaSupport", + "inputPaths": ["third_party/jdtls/manifest.json", "third_party/jdk/manifest.json", "scripts/prepare-jdtls.sh", "scripts/prepare-jdk.sh", "scripts/build-official-plugins.sh"], + "assets": [ + {"assetName": "Lithe-Java-Support-{version}-darwin-arm64.zip", "platforms": ["darwin-arm64"], "arch": "arm64"}, + {"assetName": "Lithe-Java-Support-{version}-darwin-x64.zip", "platforms": ["darwin-x64"], "arch": "x86_64"} + ], + "index": { + "name": "Java Support", + "author": "Lithe", + "repository": "https://github.com/1lck/Lithe-IDEA", + "description": "Java language support with JDT LS, Lombok, debug and test extensions, a language-server JDK, and project generators.", + "tags": ["java", "language"], + "license": "Multiple; see bundled third-party licenses" + } + } + ] } diff --git a/docs/ci-builds.md b/docs/ci-builds.md index dbc151d0a4..f25b8174f9 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -684,6 +684,35 @@ Inter 4.1 的 18 个静态 OTF(内部版本 4.001)及许可、JetBrains Mono 可下载的官方插件包是声明式插件包(只含 `plugin.json` 和它声明的资源,不含宿主加载的代码,与架构无关;自带运行时的 Java Support 见下文),在 `.build///OfficialPlugins` 中独立组装,`--triple` 只决定输出目录和签名工具的构建布局;组装后通过 `LitheOfficialPluginVerifier` 验证声明、打包的可执行文件和“Mach-O 原生代码只出现在清单声明的运行时目录内”;发布配置还要求 repository secret `LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`,由 `LithePluginPackageSigner` 对完整包生成 `lithe-plugin-signature.json`,客户端按宿主内置官方插件策略和 publisher 公钥验证后才允许安装。该 secret 的值是与客户端内置公钥匹配的 base64 编码 32 字节 Ed25519 私钥,可用 `gh secret set LITHE_PLUGIN_PACKAGE_PRIVATE_KEY --repo 1lck/Lithe-IDEA < /secure/path/lithe-plugin-package-private-key.base64` 配置;私钥文件和 shell 历史都不得进入仓库或日志。没有该 key 时,普通 debug 全量构建会跳过 publisher 签名包,指定其中一个包 ID 的构建会失败,不会留下缺少签名文档的目录。本地调试插件时可以显式传入 `--development-unsigned`(仅 debug 配置可用),生成不含签名文档的开发包,只能由 debug 构建的 Lithe 从磁盘安装;这类开发包同样不跨工作树复用。无可靠 identity stamp,不跨工作树复制。插件安装后打包的语言服务器位于 `/Lithe/Plugins//versions//language-server`,由插件版本目录拥有,运行时只读,重装、回滚和卸载随插件一起处理,不是工作树构建缓存。Go Support 和 PHP Support 已迁移为 Lithe Store 社区插件(`Lithe-IDEA/lithe-plugin-go`、`Lithe-IDEA/lithe-plugin-php`),不再由本仓库构建。PHPUnit 测试夹具的 `shared/fixtures/phpunit-project/vendor` 也由当前工作树独立安装。以上项目在资源清单 `excludedResources` 中明确排除,复用脚本会拒绝显式复制请求。 +### Java Support 独立下载安装基础 + +Java Support 当前仍随应用发行,独立下载的构建配置已登记,但 +`Plugins/marketplace/official-plugins.json` 中的 `releaseEnabled: false` 暂停自动发布。 +等支持 `packageTrust: 1` 的宿主发行、迁移验收和真实下载包验证完成后,再单独启用发布; +不要在基础设施 PR 中提前上架旧宿主无法安装的包。 + +同一份清单要求下载版的 `publisherPackage` 签名,并声明内置副本的 +`bundledSignatureRequirement: sameTeamAsHost`。应用组装必须显式用 `--bundled-only`, +由 app 自身签名封住完整包;普通单插件构建在正式配置中始终要求发布者私钥。 +本地开发只能用 debug + `--development-unsigned`,不能把开发包用于正式发布。 +原生 JDK 使用构建时固定、校验过的上游签名,包内最终字节完成后再生成 Ed25519 签名。 + +发布计划包含 `darwin-arm64` 和 `darwin-x64` 两个 ZIP,各自一个 SHA-256; +源码和资源输入变化后需要新的插件版本。重试索引发布时,从对应 tag 读取 +配置、清单和 API/`requires`,下载并计算全部资产哈希后一次写入版本,不能读取 +当前工作树的清单来描述历史包。目录如下: + +- `dist/packages///`:本次发布组装的最终签名包。 +- `dist/verify///`:归档解压后的签名验证副本。 +- `dist/Lithe-Java-Support--darwin-.zip`:按架构的发布归档。 +- `.artifacts/java-plugin-distribution/`:本地真实资源组装、归档、签名和启动检查输出。 + +这些输出和 `.build/.../OfficialPlugins` 一样没有可跨工作树转移的 identity stamp, +全部进入 `official-plugin-packages` 排除路由;不允许复制或共享。需要复用的只有 +经既有 `jdtls` / `jdk` 路由核验的下载缓存,再在目标工作树重新组装。 +临时验证副本由本次验证清理,发布 runner 的 `dist` 随 job 结束回收;持久安装的 +版本包由宿主包管理器负责,运行时保持只读。它们不改变应用签名或 Sparkle 源基线。 + Java Support(`dev.lithe.plugin.java-support`)是唯一随 Lithe.app 发行的官方插件包, 也是唯一自带运行时的包,因此它与架构有关: diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift index 51f844a56d..4eaaca65d1 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift @@ -714,6 +714,10 @@ final class MacPluginPackageStore { private func validateLaunchResources(_ descriptor: DeclarativePluginDescriptor, at packageURL: URL) throws { let root = packageURL.standardizedFileURL.resolvingSymlinksInPath() for server in descriptor.languageServers { + if case .runtime = server.launch { + try validateRuntimeLanguageServerLaunch(server.languageID, of: descriptor, at: root) + continue + } guard case .packaged(let relativePath) = server.launch else { continue } let executableURL = root.appendingPathComponent(relativePath).standardizedFileURL let values = try? executableURL.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) @@ -732,6 +736,36 @@ final class MacPluginPackageStore { } } + /// A runtime launch can reference both an interpreter and entry/resources. + /// Validate the target architecture before installing a signed but broken + /// package, without starting the runtime or creating writable state. + private func validateRuntimeLanguageServerLaunch( + _ languageID: String, + of descriptor: DeclarativePluginDescriptor, + at root: URL + ) throws { + let missing = { (path: String) in PluginPackageStoreError.languageServerLaunchMissing(descriptor.id, path) } + guard let launchPlanner else { throw missing("runtime") } + let unused = fileManager.temporaryDirectory.appendingPathComponent("lithe-language-validation", isDirectory: true) + let plan: DeclarativeLanguageServerLaunchPlan + do { + plan = try launchPlanner.languageServerLaunchPlan( + manifest: descriptor.normalizedManifest, + languageID: languageID, + request: DeclarativeLanguageServerLaunchRequest( + pluginDirectory: root, + stateDirectory: unused.appendingPathComponent("state", isDirectory: true), + workspaceStateDirectory: unused.appendingPathComponent("workspace", isDirectory: true), + architecture: MacPluginLanguageServerLaunchResolver.hostArchitecture, + settings: [:] + ) + ) + } catch let rejection as DeclarativePluginRejection { + throw PluginPackageStoreError.declarationRejected(descriptor.id, rejection) + } + try validatePlannedFiles(plan, at: root, missing: missing) + } + /// Expands the backend launch with a throwaway state directory and /// checks every package path it points at, like a provider's. private func validateBackendLaunch(of descriptor: DeclarativePluginDescriptor, at root: URL) throws { @@ -831,7 +865,8 @@ final class MacPluginPackageStore { private func validateOfficialTrustPolicy(_ manifest: PluginManifest) throws { guard let trusted = OfficialPluginCatalog.entry(for: manifest.id) else { return } - guard manifest.vendor.signatureRequirement == trusted.signatureRequirement else { + guard manifest.vendor.signatureRequirement == trusted.signatureRequirement, + manifest.vendor.bundledSignatureRequirement == trusted.bundledSignatureRequirement else { throw PluginPackageStoreError.officialSignatureRequirementMismatch(manifest.id) } } @@ -978,17 +1013,22 @@ struct MacOfficialPluginSignatureVerifier: PluginPackageSignatureVerifying { // packages trusted through the host installation, so a forged or // damaged document is never silently ignored. let signatureRequirement = Self.signatureRequirement(for: manifest) - if signatureRequirement == .publisherPackage || Self.hasPackageSignature(at: packageURL) { + let hostBundlePath = hostBundleURL.standardizedFileURL.resolvingSymlinksInPath().path + "/" + let packagePath = packageURL.standardizedFileURL.resolvingSymlinksInPath().path + let usesHostSeal = origin == .bundled + && packagePath.hasPrefix(hostBundlePath) + && manifest.vendor.bundledSignatureRequirement == .sameTeamAsHost + && OfficialPluginCatalog.entry(for: manifest.id)?.bundledSignatureRequirement == .sameTeamAsHost + if (signatureRequirement == .publisherPackage && !usesHostSeal) || Self.hasPackageSignature(at: packageURL) { try verifyPublisherPackageSignature(packageAt: packageURL, manifest: manifest) } + if usesHostSeal { return } if signatureRequirement == .publisherPackage { return } // `sameTeamAsHost` has no code signature to compare any more: the // package is trusted only inside the host bundle, whose code signature // seals every resource it contains. - let hostBundlePath = hostBundleURL.standardizedFileURL.resolvingSymlinksInPath().path + "/" - let packagePath = packageURL.standardizedFileURL.resolvingSymlinksInPath().path guard origin == .bundled, packagePath.hasPrefix(hostBundlePath) else { throw PluginPackageStoreError.hostSealRequired(manifest.id) } diff --git a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift index ca45631600..6292e6ce30 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift @@ -768,7 +768,8 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { vendor: PluginVendor( id: decoded.vendor.id, displayName: decoded.vendor.displayName, - signatureRequirement: decoded.vendor.signatureRequirement + signatureRequirement: decoded.vendor.signatureRequirement, + bundledSignatureRequirement: decoded.vendor.bundledSignatureRequirement ), entrypoint: .declarative, modules: modules, @@ -799,6 +800,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let id: String let displayName: String let signatureRequirement: PluginSignatureRequirement + let bundledSignatureRequirement: PluginSignatureRequirement? } struct Dependencies: Decodable { let managementFileNames: [String] diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index 91ac3258dc..f295e10e4e 100644 --- a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -217,6 +217,9 @@ public enum OfficialPluginCatalog { public let id: PluginID public let displayName: String public let signatureRequirement: PluginSignatureRequirement + /// The host explicitly permits its own sealed copy to omit the + /// publisher signature. This never applies to user-installed copies. + public var bundledSignatureRequirement: PluginSignatureRequirement? = nil public let distribution: Distribution public let languageID: String public let languageDisplayName: String @@ -239,7 +242,8 @@ public enum OfficialPluginCatalog { Entry( id: javaPluginID, displayName: "Java Support", - signatureRequirement: .sameTeamAsHost, + signatureRequirement: .publisherPackage, + bundledSignatureRequirement: .sameTeamAsHost, distribution: .bundled, languageID: "java", languageDisplayName: "Java", diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index 84639f226d..8fb2e79b63 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -110,15 +110,20 @@ public struct PluginVendor: Equatable, Codable, Sendable { public let id: String public let displayName: String public let signatureRequirement: PluginSignatureRequirement + /// Only a host-pinned bundled package may use this alternate trust. A + /// downloaded or disk-imported copy always needs the primary signature. + public let bundledSignatureRequirement: PluginSignatureRequirement? public init( id: String, displayName: String, - signatureRequirement: PluginSignatureRequirement + signatureRequirement: PluginSignatureRequirement, + bundledSignatureRequirement: PluginSignatureRequirement? = nil ) { self.id = id self.displayName = displayName self.signatureRequirement = signatureRequirement + self.bundledSignatureRequirement = bundledSignatureRequirement } } @@ -490,6 +495,7 @@ public struct PluginManifest: Equatable, Codable, Sendable { /// level 3 plugin that requires anything else. Validating a capability is /// not running it, so an entry is added only with its host support. public static let hostCapabilities: [String: [Int]] = [ + "packageTrust": [1], "backend": [1], "buildProviders": [1], "buildSystems": [1, 2], "executableResolution": [1], diff --git a/macos/Tests/LitheOfficialPluginVerifier/OfficialPluginVerifier.swift b/macos/Tests/LitheOfficialPluginVerifier/OfficialPluginVerifier.swift index e869504a84..ea4b455a57 100644 --- a/macos/Tests/LitheOfficialPluginVerifier/OfficialPluginVerifier.swift +++ b/macos/Tests/LitheOfficialPluginVerifier/OfficialPluginVerifier.swift @@ -25,6 +25,7 @@ struct OfficialPluginVerifier { let manifest = descriptor.pluginManifest guard let entry = OfficialPluginCatalog.entry(for: manifest.id), entry.signatureRequirement == manifest.vendor.signatureRequirement, + entry.bundledSignatureRequirement == manifest.vendor.bundledSignatureRequirement, packageURL.lastPathComponent == manifest.id.rawValue else { throw VerificationError.trustPolicyMismatch(manifest.id.rawValue) } @@ -100,8 +101,8 @@ struct OfficialPluginVerifier { /// The host never loads plugin code, so Mach-O files are allowed only in /// the top-level directory of a runtime the plugin declares with - /// `packagedRuntime`. Such a package ships inside the signed application, - /// whose code signature covers the runtime's binaries. + /// `packagedRuntime`. Bundled builds use the application's seal; independently + /// installed builds require a publisher signature over the final runtime. private static func rejectNativeCode(in packageURL: URL, exceptIn runtimeDirectories: [String]) throws { let machOMagics: Set = [0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe, 0xbebafeca] guard let enumerator = FileManager.default.enumerator( diff --git a/macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift b/macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift new file mode 100644 index 0000000000..4df64d65a4 --- /dev/null +++ b/macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift @@ -0,0 +1,166 @@ +import CryptoKit +import Foundation +@testable import Lithe +import LitheCoreContracts +import LitheModuleAPI +import LithePluginPackageSigning +import Testing + +/// Runs production validation, signature verification and package storage. +/// No fixture process executes, and all files belong to this test's root. +struct PluginBundledPublisherTrustTests { + @Test + func hostSealedCopyAndSignedInstalledUpdateUseOnePluginIdentity() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let before = try fixture.digest(of: fixture.bundled) + let initial = try fixture.store().scanInstalledPlugins() + #expect(initial.issues.isEmpty) + #expect(initial.packages.map(\.installation.origin) == [.bundled]) + + try fixture.sign(fixture.download) + let installed = try fixture.store().installPackage(from: fixture.download) + let restarted = try fixture.store().scanInstalledPlugins() + #expect(restarted.issues.isEmpty) + #expect(restarted.packages.count == 1) + #expect(restarted.packages.first?.packageURL.resolvingSymlinksInPath() == installed.packageURL.resolvingSymlinksInPath()) + #expect(restarted.packages.first?.installation.origin == .marketplace) + #expect(try fixture.digest(of: fixture.bundled) == before) + #expect(try fixture.digest(of: installed.packageURL) == fixture.digest(of: fixture.download)) + } + + @Test + func unsignedOutsideCopyCannotClaimTheBundledTrustException() throws { + let fixture = try Fixture() + defer { fixture.remove() } + for origin: PluginInstallationOrigin in [.marketplace, .bundled, .localDevelopment] { + #expect(throws: (any Error).self) { + _ = try fixture.store().verifyCandidatePackage( + at: fixture.download, origin: origin, marketplace: nil + ) + } + } + #expect(try fixture.store().scanInstalledPlugins().packages.first?.installation.origin == .bundled) + } + + @Test + func presentInvalidSignatureIsRejectedEvenInsideTheHostSeal() throws { + let fixture = try Fixture() + defer { fixture.remove() } + try fixture.sign(fixture.bundled) + try Data("tampered".utf8).write(to: fixture.bundled.appendingPathComponent("server.jar")) + let scan = try fixture.store().scanInstalledPlugins() + #expect(scan.packages.isEmpty) + #expect(scan.issues.count == 1) + } + + @Test + func installedRuntimeTamperingCannotFallBackToHostSeal() throws { + let fixture = try Fixture() + defer { fixture.remove() } + try fixture.sign(fixture.download) + let installed = try fixture.store().installPackage(from: fixture.download) + try Data("tampered".utf8).write(to: installed.packageURL.appendingPathComponent("runtime/arm64/bin/java")) + let scan = try fixture.store().scanInstalledPlugins() + #expect(scan.issues.count == 1) + // The damaged update is isolated; the immutable bundled source remains. + #expect(scan.packages.map(\.installation.origin) == [.bundled]) + } + + @Test + func officialPackageCannotChangeItsPinnedBundledTrust() throws { + let fixture = try Fixture() + defer { fixture.remove() } + var manifest = fixture.manifest + var vendor = try #require(manifest["vendor"] as? [String: Any]) + vendor.removeValue(forKey: "bundledSignatureRequirement") + manifest["vendor"] = vendor + var requires = try #require(manifest["requires"] as? [String: Int]) + requires.removeValue(forKey: "packageTrust") + manifest["requires"] = requires + try JSONSerialization.data(withJSONObject: manifest).write(to: fixture.download.appendingPathComponent("plugin.json")) + try fixture.sign(fixture.download) + #expect(throws: PluginPackageStoreError.officialSignatureRequirementMismatch(OfficialPluginCatalog.javaPluginID)) { + _ = try fixture.store().installPackage(from: fixture.download) + } + } + + @Test + func signedPackageMissingTheHostArchitectureRuntimeIsRejected() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let arch = MacPluginLanguageServerLaunchResolver.hostArchitecture + try FileManager.default.removeItem(at: fixture.download.appendingPathComponent("runtime/\(arch)/bin/java")) + try fixture.sign(fixture.download) + #expect(throws: (any Error).self) { + _ = try fixture.store().installPackage(from: fixture.download) + } + #expect(try fixture.store().scanInstalledPlugins().packages.map(\.installation.origin) == [.bundled]) + } + + private struct Fixture { + let root: URL + let host: URL + let bundled: URL + let download: URL + let manifest: [String: Any] + let privateKey: Curve25519.Signing.PrivateKey + + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-publisher-bundled-\(UUID().uuidString)") + host = root.appendingPathComponent("Lithe.app") + bundled = host.appendingPathComponent("Contents/Resources/OfficialPlugins/dev.lithe.plugin.java-support") + download = root.appendingPathComponent("download/dev.lithe.plugin.java-support") + privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 9, count: 32)) + let url = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/plugins/v1/fixtures/publisher-bundled.plugin.json") + var object = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) + object["id"] = OfficialPluginCatalog.javaPluginID.rawValue + manifest = object + do { + for package in [bundled, download] { + try DeclarativePluginTestSupport.writePackage(at: package, manifest: manifest) + for arch in ["arm64", "x86_64"] { + let java = package.appendingPathComponent("runtime/\(arch)/bin/java") + try FileManager.default.createDirectory(at: java.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("fixture runtime".utf8).write(to: java) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: java.path) + } + try Data("fixture server".utf8).write(to: package.appendingPathComponent("server.jar")) + } + } catch { + try? FileManager.default.removeItem(at: root) + throw error + } + } + + func store() -> MacPluginPackageStore { + MacPluginPackageStore( + rootURL: root.appendingPathComponent("installed"), + bundledRootURL: bundled.deletingLastPathComponent(), + validator: DeclarativePluginTestSupport.operations, + verifier: MacOfficialPluginSignatureVerifier( + trustedPublisherKeys: [PluginPackageSignature.keyID: privateKey.publicKey], + allowsLocalDevelopmentPackages: false, + hostBundleURL: host + ) + ) + } + + func sign(_ package: URL) throws { + let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: Data(contentsOf: package.appendingPathComponent("plugin.json"))) + let document = try PluginPackageSignature.makeDocument( + packageAt: package, + pluginID: descriptor.id.rawValue, + pluginVersion: descriptor.pluginManifest.version.description, + privateKey: privateKey + ) + try PluginPackageSignature.write(document, to: package) + } + + func digest(of package: URL) throws -> String { + try PluginPackageSignature.contentDigest(packageAt: package, fileManager: FileManager.default) + } + + func remove() { try? FileManager.default.removeItem(at: root) } + } +} diff --git a/macos/Tests/LitheTests/ProjectGenerationTests.swift b/macos/Tests/LitheTests/ProjectGenerationTests.swift index aa0fcd935d..9d4c1d3a95 100644 --- a/macos/Tests/LitheTests/ProjectGenerationTests.swift +++ b/macos/Tests/LitheTests/ProjectGenerationTests.swift @@ -342,6 +342,11 @@ struct ProjectGenerationTests { templates["displayName"] = "Java Templates" templates["permissions"] = ["project.generate"] templates["requires"] = ["projectGenerators": 1] + // This separate template fixture is not the host-pinned bundled Java + // package, so it does not inherit that package's origin trust. + var templateVendor = manifest["vendor"] as? [String: Any] ?? [:] + templateVendor["bundledSignatureRequirement"] = nil + templates["vendor"] = templateVendor templates["activationEvents"] = [String]() func plugin(_ object: [String: Any], id: PluginID) throws -> ProjectGeneratorPluginSource { ProjectGeneratorPluginSource( diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index 33e7a4aadd..afb7f09a43 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -29,6 +29,9 @@ pub(crate) const LANGUAGES: &str = "languages"; pub(crate) const LANGUAGE_SERVERS: &str = "languageServers"; /// The launch fields language servers and own build providers share. pub(crate) const SERVER_PROCESS: &str = "serverProcess"; +/// `vendor.bundledSignatureRequirement`: origin-scoped host-seal trust for a +/// package whose independently installed versions require a publisher signature. +pub(crate) const PACKAGE_TRUST: &str = "packageTrust"; /// `executionProviders[]`: run and test templates. pub(crate) const EXECUTION_PROVIDERS: &str = "executionProviders"; /// `settings[]`. Version 2 adds scopes, effects, settings pages, and the @@ -65,6 +68,7 @@ pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (EXECUTABLE_RESOLUTION, &[1]), (LANGUAGE_SERVERS, &[1, 2]), (LANGUAGES, &[1]), + (PACKAGE_TRUST, &[1]), (PROJECT_GENERATORS, &[1]), (SERVER_PROCESS, &[1]), (SETTINGS, &[1, 2]), diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 1a4bd9518a..2e92d04e3d 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -360,6 +360,10 @@ struct PluginVendor { id: String, display_name: String, signature_requirement: SignatureRequirement, + /// Optional trust for the host's sealed built-in copy only. Installation + /// from every other origin still requires the primary publisher signature. + #[serde(default, skip_serializing_if = "Option::is_none")] + bundled_signature_requirement: Option, } /// How a package proves its origin. `sameTeamAsHost` is only honored by a @@ -795,6 +799,18 @@ impl DeclarativePluginManifest { "Official plugin IDs must carry a Lithe signature.", )); } + if let Some(bundled) = self.vendor.bundled_signature_requirement { + if self.plugin_api_version < CAPABILITIES_API_VERSION + || self.vendor.signature_requirement != SignatureRequirement::PublisherPackage + || bundled != SignatureRequirement::SameTeamAsHost + || !is_official_namespace(&self.id) + { + return Err(invalid( + "vendor.bundledSignatureRequirement", + "Bundled host-seal trust requires API 3, an official plugin ID, and publisherPackage for installed copies.", + )); + } + } if self.plugin_api_version >= CAPABILITIES_API_VERSION { capabilities::check_used(&self.requires, &self.used_capabilities())?; } @@ -982,6 +998,10 @@ impl DeclarativePluginManifest { /// server, own build provider, and VCS provider is a `serverProcess`. fn used_capabilities(&self) -> BTreeSet<&'static str> { let sections = [ + ( + capabilities::PACKAGE_TRUST, + self.vendor.bundled_signature_requirement.is_some(), + ), ( capabilities::EXECUTABLE_RESOLUTION, self.build_systems.iter().any(|system| { diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index c8629895f1..3ac3f03618 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -21,6 +21,7 @@ const WEB_PANEL_FIXTURE: &str = contract_file!("fixtures/web-panel.plugin.json") const GO_FIXTURE: &str = contract_file!("fixtures/language-system-command.plugin.json"); const JAVA_FIXTURE: &str = contract_file!("fixtures/language-host-integration.plugin.json"); const NODE_RUNTIME_FIXTURE: &str = contract_file!("fixtures/node-runtime.plugin.json"); +const PUBLISHER_BUNDLED_FIXTURE: &str = contract_file!("fixtures/publisher-bundled.plugin.json"); const CARGO_BUILD_FIXTURE: &str = contract_file!("fixtures/build-static-file.plugin.json"); const GO_BUILD_FIXTURE: &str = contract_file!("fixtures/build-command-stdout.plugin.json"); const GRADLE_BUILD_FIXTURE: &str = contract_file!("fixtures/build-command-output-file.plugin.json"); @@ -72,6 +73,7 @@ fn contract_fixtures_and_official_plugins_validate() { WEB_PANEL_FIXTURE, JAVA_FIXTURE, NODE_RUNTIME_FIXTURE, + PUBLISHER_BUNDLED_FIXTURE, BACKEND_FIXTURE, OFFICIAL_JAVA, ] { @@ -80,6 +82,59 @@ fn contract_fixtures_and_official_plugins_validate() { } } +/// The package cannot grant itself host-seal trust on an older host or +/// downgrade independently installed copies to unsigned/local-only trust. +#[test] +fn bundled_publisher_trust_requires_an_explicit_host_capability() { + let manifest: Value = serde_json::from_str(PUBLISHER_BUNDLED_FIXTURE).unwrap(); + let mut host = json!({"languages":[1], "languageServers":[1], "serverProcess":[1]}); + let refused = + validate_command(json!({"manifest": manifest, "hostCapabilities": host})).unwrap(); + assert_eq!(refused["error"]["code"], "plugin.api.unsupported"); + assert_eq!(refused["error"]["field"], "requires.packageTrust"); + host["packageTrust"] = json!([1]); + let accepted = + validate_command(json!({"manifest": manifest, "hostCapabilities": host})).unwrap(); + assert_eq!(accepted["status"], "ok", "{accepted}"); + assert_eq!( + accepted["plugin"]["vendor"]["bundledSignatureRequirement"], + "sameTeamAsHost" + ); + + for (field, value) in [ + ("signatureRequirement", "none"), + ("signatureRequirement", "sameTeamAsHost"), + ("bundledSignatureRequirement", "publisherPackage"), + ("bundledSignatureRequirement", "none"), + ] { + let mut bad = manifest.clone(); + bad["vendor"][field] = json!(value); + assert_eq!(validate(bad)["status"], "rejected", "{field}: {value}"); + } + let mut community = manifest.clone(); + community["id"] = json!("io.example.fixture"); + assert_eq!(validate(community)["status"], "rejected"); + let mut missing = manifest.clone(); + missing["requires"] + .as_object_mut() + .unwrap() + .remove("packageTrust"); + assert_eq!(validate(missing)["error"]["field"], "requires"); + let mut legacy = manifest.clone(); + legacy["pluginAPIVersion"] = json!(2); + legacy.as_object_mut().unwrap().remove("requires"); + assert_eq!( + validate(legacy)["error"]["field"], + "vendor.bundledSignatureRequirement" + ); + let mut unused = manifest; + unused["vendor"] + .as_object_mut() + .unwrap() + .remove("bundledSignatureRequirement"); + assert_eq!(validate(unused)["error"]["field"], "requires.packageTrust"); +} + #[test] fn validation_normalizes_order_and_spelling_without_touching_arguments() { let mut manifest = fixture("language-packaged-server.plugin.json"); diff --git a/rust/lithe-core/src/tests/marketplace_plugins.rs b/rust/lithe-core/src/tests/marketplace_plugins.rs index 38ec2e802a..a88cbf56b3 100644 --- a/rust/lithe-core/src/tests/marketplace_plugins.rs +++ b/rust/lithe-core/src/tests/marketplace_plugins.rs @@ -396,3 +396,45 @@ fn host_capabilities_do_not_change_legacy_levels() { assert_eq!(plugin["availability"]["status"], expected, "{result}"); } } + +/// Download availability must reject the new trust semantics before any +/// archive is transferred, even when the host already runs every Java feature. +#[test] +fn publisher_bundled_versions_require_host_trust_support_before_download() { + let mut index = json_file(INDEX); + let entry = index["plugins"] + .as_array_mut() + .unwrap() + .iter_mut() + .find(|entry| entry["id"] == "io.example.elixir-tools") + .unwrap(); + entry["versions"] = json!([{ + "version":"3.0.0", "pluginAPIVersion":3, "releaseDate":"2026-10-25", + "requires":{"languages":1,"packageTrust":1}, + "downloads":[{"platforms":["darwin-arm64"], + "downloadURL":"https://github.com/example/elixir-tools/releases/download/v3.0.0/elixir.zip", + "sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}] + }]); + // Reuse the fixture's repository so only capability availability varies. + let repository = entry["repository"].as_str().unwrap().to_string(); + entry["versions"][0]["downloads"][0]["downloadURL"] = + json!(format!("{repository}/releases/download/v3.0.0/elixir.zip")); + for (capabilities, expected) in [ + (json!({"languages":[1]}), "unsupported"), + (json!({"languages":[1],"packageTrust":[1]}), "available"), + ] { + let outcome = validate_for_host( + index.clone(), + "thirdParty", + "darwin-arm64", + Some(&capabilities), + ); + let plugin = outcome["index"]["plugins"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["id"] == "io.example.elixir-tools") + .unwrap(); + assert_eq!(plugin["availability"]["status"], expected, "{outcome}"); + } +} diff --git a/rust/lithe-core/src/tests/project_generators.rs b/rust/lithe-core/src/tests/project_generators.rs index 934a8a8291..50d1dd4539 100644 --- a/rust/lithe-core/src/tests/project_generators.rs +++ b/rust/lithe-core/src/tests/project_generators.rs @@ -58,7 +58,7 @@ fn official_package_path() -> PathBuf { fn host() -> Value { json!({"languages": [1], "languageServers": [1, 2], "serverProcess": [1], "settings": [1, 2], - "buildSystems": [1], "projectGenerators": [1]}) + "buildSystems": [1], "projectGenerators": [1], "packageTrust": [1]}) } #[test] diff --git a/scripts/build-official-plugins.sh b/scripts/build-official-plugins.sh index b35b7843f9..31a8d40bed 100755 --- a/scripts/build-official-plugins.sh +++ b/scripts/build-official-plugins.sh @@ -91,8 +91,16 @@ for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do exit 1 fi signature_requirement=$(/usr/bin/plutil -extract vendor.signatureRequirement raw "$manifest") + bundled_signature_requirement=$(/usr/bin/plutil -extract vendor.bundledSignatureRequirement raw "$manifest" 2>/dev/null || true) + # The same manifest describes both distributions. Only the explicitly + # selected application-bundled build may rely on the application's seal; + # standalone packages always go through publisher signing below. + uses_host_seal=false + if [[ "$BUNDLED_ONLY" == true && "$bundled_signature_requirement" == "sameTeamAsHost" ]]; then + uses_host_seal=true + fi publisher_signed=false - if [[ "$signature_requirement" == "publisherPackage" && "$DEVELOPMENT_UNSIGNED" == false ]]; then + if [[ "$signature_requirement" == "publisherPackage" && "$DEVELOPMENT_UNSIGNED" == false && "$uses_host_seal" == false ]]; then publisher_signed=true fi if [[ "$publisher_signed" == true && -z "${LITHE_PLUGIN_PACKAGE_PRIVATE_KEY:-}" ]]; then @@ -118,7 +126,7 @@ for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do "$package_assembler" --output "$package_dir" --arch "$RUNTIME_ARCH" >&2 fi - if [[ "$signature_requirement" == "publisherPackage" && "$publisher_signed" == false ]]; then + if [[ "$signature_requirement" == "publisherPackage" && "$publisher_signed" == false && "$uses_host_seal" == false ]]; then print -u2 -- "Built unsigned development package $package_id; only development builds of Lithe can install it" fi if [[ "$publisher_signed" == true ]]; then diff --git a/scripts/official-plugin-distribution.mjs b/scripts/official-plugin-distribution.mjs index f29ce82b96..a1819d9859 100644 --- a/scripts/official-plugin-distribution.mjs +++ b/scripts/official-plugin-distribution.mjs @@ -3,8 +3,8 @@ // remain separately buildable and installable through plugin management. // Go Support and PHP Support moved to community plugins in Lithe Store. Java // Support ships inside Lithe.app so Java works offline on first launch; its -// manifest uses `sameTeamAsHost`, and the user can remove and restore it in -// plugin management. +// manifest allows the sealed bundled copy while installed updates require a +// publisher signature. The user can remove and restore it in plugin management. import { pathToFileURL } from "node:url"; const bundledOfficialPluginIDs = new Set(["dev.lithe.plugin.java-support"]); export function isBundledOfficialPlugin(id) { diff --git a/scripts/official-plugin-releases.mjs b/scripts/official-plugin-releases.mjs index 8537b37086..f677e2d468 100644 --- a/scripts/official-plugin-releases.mjs +++ b/scripts/official-plugin-releases.mjs @@ -1,57 +1,43 @@ #!/usr/bin/env node -// Plans and records official plugin releases. Each official downloadable -// plugin has its own tag series (``); a tag is created -// only when the plugin's package directory changed and its plugin.json -// declares a version that has no tag yet. See -// .agents/notes/implemented/feature/2026-10-12-plugin-marketplace.md. -// -// plan JSON list of plugins whose version is untagged -// pending-index JSON list of tagged versions missing from -// index.json; publishing and listing are separate -// steps, so a failed listing is retried from here -// check fail when a tagged version's directory changed -// update-index -// add the released archive to the official index +// Plan immutable official plugin releases and list their exact tagged inputs. +// An index retry must never describe an old archive using today's manifest. import { execFileSync } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const root = join(fileURLToPath(new URL(".", import.meta.url)), ".."); -const configPath = join(root, "Plugins/marketplace/official-plugins.json"); +const configFile = "Plugins/marketplace/official-plugins.json"; +const configPath = join(root, configFile); const indexPath = join(root, "Plugins/marketplace/index.json"); +const versionPattern = /^\d+\.\d+\.\d+$/; export function loadConfig(path = configPath) { const config = JSON.parse(readFileSync(path, "utf8")); - if (config.schemaVersion !== 1) throw new Error("official-plugins.json must use schemaVersion 1"); + if (config.schemaVersion !== 1 || !Array.isArray(config.plugins)) { + throw new Error("official-plugins.json must use schemaVersion 1"); + } return config.plugins; } -function git(args, options = {}) { - return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...options }).trim(); +function git(args) { + return execFileSync("git", args, { + cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30_000, + }).trim(); } function tagExists(tag) { - try { - git(["rev-parse", "--verify", "--quiet", `refs/tags/${tag}`]); - return true; - } catch { - return false; - } + try { git(["rev-parse", "--verify", "--quiet", `refs/tags/${tag}`]); return true; } + catch (error) { if (error.status === 1) return false; throw error; } } function tagsWithPrefix(prefix) { - const output = git(["tag", "--list", `${prefix}*`]); - return output ? output.split("\n") : []; + return git(["tag", "--list", `${prefix}*`]).split("\n").filter(Boolean); } -function directoryChangedSince(tag, directory) { - try { - git(["diff", "--quiet", tag, "HEAD", "--", directory]); - return false; - } catch { - return true; - } +function changedSince(tag, paths) { + try { git(["diff", "--quiet", tag, "HEAD", "--", ...paths]); return false; } + catch (error) { if (error.status === 1) return true; throw error; } } function readPackageManifest(directory) { @@ -60,99 +46,126 @@ function readPackageManifest(directory) { export function releaseFor(plugin, readManifest = readPackageManifest) { const manifest = readManifest(plugin.packageDirectory); - if (manifest.id !== plugin.id) { - throw new Error(`${plugin.packageDirectory}/plugin.json has id ${manifest.id}, expected ${plugin.id}`); + if (manifest.id !== plugin.id) throw new Error("Plugin manifest ID does not match its release configuration"); + if (!versionPattern.test(manifest.version)) throw new Error(`${plugin.id} version must be major.minor.patch`); + if (![1, 2, 3].includes(manifest.pluginAPIVersion)) throw new Error("Missing or unsupported pluginAPIVersion"); + if (!/^[a-z0-9-]+-v$/.test(plugin.tagPrefix)) throw new Error("Invalid plugin tag prefix"); + const repository = plugin.index.repository.replace(/\/$/, ""); + if (!/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { + throw new Error("Invalid plugin release repository"); } - if (!/^\d+\.\d+\.\d+$/.test(manifest.version)) { - throw new Error(`${plugin.id} version must be major.minor.patch`); + const platforms = new Set(); + const names = new Set(); + const assets = (plugin.assets ?? [{ assetName: plugin.assetName, platforms: plugin.platforms }]).map((asset) => { + const assetName = asset.assetName?.replaceAll("{version}", manifest.version); + if (!assetName || !/^[A-Za-z0-9_.-]+\.zip$/.test(assetName) || names.has(assetName)) { + throw new Error("Invalid or duplicate plugin asset name"); + } + names.add(assetName); + if (!Array.isArray(asset.platforms) || !asset.platforms.length) throw new Error("An asset needs platforms"); + for (const platform of asset.platforms) { + if (!/^[a-z0-9]+-[a-z0-9]+$/.test(platform) || platforms.has(platform)) { + throw new Error("Invalid or duplicate plugin platform"); + } + platforms.add(platform); + } + if (asset.arch !== undefined && !["arm64", "x86_64", "universal"].includes(asset.arch)) { + throw new Error("Invalid macOS runtime architecture"); + } + if (asset.arch && asset.platforms.some((platform) => + !({ arm64: ["darwin-arm64"], x86_64: ["darwin-x64"], universal: ["darwin-arm64", "darwin-x64"] }[asset.arch]).includes(platform))) { + throw new Error("Plugin asset platforms do not match its runtime architecture"); + } + return { assetName, platforms: [...asset.platforms], ...(asset.arch ? { arch: asset.arch } : {}) }; + }); + if (!assets.length) throw new Error("A release needs assets"); + let requires; + if (manifest.pluginAPIVersion === 3) { + if (!manifest.requires || typeof manifest.requires !== "object" || Array.isArray(manifest.requires) + || !Object.keys(manifest.requires).length + || Object.entries(manifest.requires).some(([id, v]) => !/^[a-z][A-Za-z0-9]*$/.test(id) || !Number.isSafeInteger(v) || v < 1)) { + throw new Error("API 3 requires the manifest's capability versions"); + } + requires = Object.fromEntries(Object.entries(manifest.requires).sort(([a], [b]) => a.localeCompare(b))); } - const version = manifest.version; return { - id: plugin.id, - version, - tag: `${plugin.tagPrefix}${version}`, - assetName: plugin.assetName.replace("{version}", version), - packageDirectory: plugin.packageDirectory, + id: plugin.id, version: manifest.version, tag: `${plugin.tagPrefix}${manifest.version}`, + packageDirectory: plugin.packageDirectory, pluginAPIVersion: manifest.pluginAPIVersion, + ...(requires ? { requires } : {}), index: { ...plugin.index, repository }, assets, }; } -/** Plugins whose current version has no tag yet. */ export function plan(plugins = loadConfig(), exists = tagExists, readManifest = readPackageManifest) { - return plugins.map((plugin) => releaseFor(plugin, readManifest)).filter((release) => !exists(release.tag)); + return plugins.filter((plugin) => plugin.releaseEnabled !== false) + .map((plugin) => releaseFor(plugin, readManifest)).filter((release) => !exists(release.tag)); +} + +// Read both configuration and manifest at the immutable release tag, including +// old asset names, API levels, and requires after subsequent plugin upgrades. +function readTaggedRelease(plugin, tag) { + const config = JSON.parse(git(["show", `${tag}:${configFile}`])); + const tagged = config.plugins.find((entry) => entry.id === plugin.id); + if (!tagged) throw new Error(`${tag} has no release configuration for ${plugin.id}`); + return releaseFor(tagged, (directory) => JSON.parse(git(["show", `${tag}:${directory}/plugin.json`]))); } -/** Released (tagged) versions that the official index does not list yet. - * Every tag of each series is considered, so a version whose listing - * failed is still picked up after the plugin has moved on. */ -export function pendingIndex(plugins = loadConfig(), index = readIndex(), tags = tagsWithPrefix) { +export function pendingIndex(plugins = loadConfig(), index = readIndex(), tags = tagsWithPrefix, readTagged = readTaggedRelease) { const pending = []; for (const plugin of plugins) { - const listed = new Set( - (index.plugins.find((entry) => entry.id === plugin.id)?.versions ?? []).map((entry) => entry.version), - ); + if (plugin.releaseEnabled === false) continue; + const listed = new Set((index.plugins.find((entry) => entry.id === plugin.id)?.versions ?? []).map((entry) => entry.version)); for (const tag of tags(plugin.tagPrefix)) { const version = tag.slice(plugin.tagPrefix.length); - if (!/^\d+\.\d+\.\d+$/.test(version) || listed.has(version)) continue; - pending.push({ - id: plugin.id, - version, - tag, - assetName: plugin.assetName.replace("{version}", version), - }); + if (!versionPattern.test(version) || listed.has(version)) continue; + const release = readTagged(plugin, tag); + if (release.tag !== tag || release.version !== version) throw new Error(`${tag} disagrees with its manifest`); + pending.push(release); } } return pending; } -function readIndex() { - return JSON.parse(readFileSync(indexPath, "utf8")); -} +function readIndex() { return JSON.parse(readFileSync(indexPath, "utf8")); } -/** Changing a released plugin without a new version would ship two - * different packages under one version, so it is an error. */ -export function check( - plugins = loadConfig(), - exists = tagExists, - changed = directoryChangedSince, - readManifest = readPackageManifest, -) { +export function check(plugins = loadConfig(), exists = tagExists, changed = changedSince, readManifest = readPackageManifest) { const problems = []; for (const plugin of plugins) { const release = releaseFor(plugin, readManifest); - if (exists(release.tag) && changed(release.tag, release.packageDirectory)) { - problems.push(`${release.id}: ${release.packageDirectory} changed since ${release.tag}; bump the version in plugin.json`); + if (exists(release.tag) && changed(release.tag, [plugin.packageDirectory, ...(plugin.inputPaths ?? [])])) { + problems.push(`${release.id}: release inputs changed since ${release.tag}; bump the version in plugin.json`); } } return problems; } -/** Inserts the released archive, keeping versions newest first. */ -export function updateIndex(index, plugin, { version, sha256, releaseDate }) { - if (!/^[0-9a-f]{64}$/.test(sha256)) throw new Error("sha256 must be 64 lowercase hex characters"); +/** List all assets atomically; a partial download cannot publish a version. */ +export function updateIndex(index, release, { checksums, releaseDate }) { if (!/^\d{4}-\d{2}-\d{2}$/.test(releaseDate)) throw new Error("releaseDate must be YYYY-MM-DD"); - const repository = plugin.index.repository.replace(/\/$/, ""); - const tag = `${plugin.tagPrefix}${version}`; - const versionEntry = { - version, - pluginAPIVersion: 1, - releaseDate, - downloads: [{ - platforms: plugin.platforms, - downloadURL: `${repository}/releases/download/${tag}/${plugin.assetName.replace("{version}", version)}`, + if (!checksums || Array.isArray(checksums) || typeof checksums !== "object" + || Object.keys(checksums).length !== release.assets.length) throw new Error("Expected every release asset checksum"); + const downloads = release.assets.map((asset) => { + const sha256 = checksums[asset.assetName]; + if (typeof sha256 !== "string" || !/^[0-9a-f]{64}$/.test(sha256)) throw new Error(`Invalid checksum for ${asset.assetName}`); + return { + platforms: [...asset.platforms], + downloadURL: `${release.index.repository}/releases/download/${release.tag}/${asset.assetName}`, sha256, - }], + }; + }); + const versionEntry = { + version: release.version, pluginAPIVersion: release.pluginAPIVersion, + ...(release.requires ? { requires: release.requires } : {}), releaseDate, downloads, }; - const plugins = index.plugins.filter((entry) => entry.id !== plugin.id); - const existing = index.plugins.find((entry) => entry.id === plugin.id); - const versions = (existing?.versions ?? []).filter((entry) => entry.version !== version); + const plugins = index.plugins.filter((entry) => entry.id !== release.id); + const existing = index.plugins.find((entry) => entry.id === release.id); + const versions = (existing?.versions ?? []).filter((entry) => entry.version !== release.version); versions.push(versionEntry); - const numeric = (value) => value.split(".").map(Number); - versions.sort((left, right) => { - const [a, b] = [numeric(left.version), numeric(right.version)]; - return b[0] - a[0] || b[1] - a[1] || b[2] - a[2]; + versions.sort((a, b) => { + const left = a.version.split(".").map(Number), right = b.version.split(".").map(Number); + return right[0] - left[0] || right[1] - left[1] || right[2] - left[2]; }); - plugins.push({ id: plugin.id, ...plugin.index, versions }); - plugins.sort((left, right) => left.id.localeCompare(right.id)); + plugins.push({ id: release.id, ...release.index, versions }); + plugins.sort((a, b) => a.id.localeCompare(b.id)); return { schemaVersion: 1, plugins }; } @@ -164,16 +177,23 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.stdout.write(`${JSON.stringify(pendingIndex())}\n`); } else if (command === "check") { const problems = check(); - for (const problem of problems) console.error(problem); + for (const problem of problems) process.stderr.write(`${problem}\n`); process.exitCode = problems.length ? 1 : 0; + } else if (command === "describe") { + const plugin = loadConfig().find((entry) => entry.id === args[0]); + if (!plugin) throw new Error("Unknown official plugin"); + process.stdout.write(`${JSON.stringify(releaseFor(plugin))}\n`); } else if (command === "update-index") { - const [id, version, sha256, releaseDate] = args; + const [id, version, checksumsJSON, releaseDate] = args; const plugin = loadConfig().find((entry) => entry.id === id); - if (!plugin) throw new Error(`${id} is not an official released plugin`); - const index = readIndex(); - writeFileSync(indexPath, `${JSON.stringify(updateIndex(index, plugin, { version, sha256, releaseDate }), null, 2)}\n`); + if (!plugin || !versionPattern.test(version)) throw new Error("Unknown plugin or invalid version"); + const tag = `${plugin.tagPrefix}${version}`; + const release = readTaggedRelease(plugin, tag); + if (release.tag !== tag) throw new Error("Release tag disagrees with manifest"); + writeFileSync(indexPath, `${JSON.stringify(updateIndex(readIndex(), release, { + checksums: JSON.parse(checksumsJSON), releaseDate, + }), null, 2)}\n`); } else { - console.error("Usage: official-plugin-releases.mjs plan | pending-index | check | update-index "); - process.exitCode = 2; + throw new Error("Usage: official-plugin-releases.mjs plan | pending-index | check | describe | update-index "); } } diff --git a/scripts/official-plugin-releases.test.mjs b/scripts/official-plugin-releases.test.mjs index 2732cdf3c6..666b062ebc 100644 --- a/scripts/official-plugin-releases.test.mjs +++ b/scripts/official-plugin-releases.test.mjs @@ -2,90 +2,93 @@ import assert from "node:assert/strict"; import test from "node:test"; import { check, loadConfig, pendingIndex, plan, releaseFor, updateIndex } from "./official-plugin-releases.mjs"; -// Go and PHP Support moved to Lithe Store, so no downloadable official plugin -// is configured right now. The release flow is kept for future official -// plugins and tested with a synthetic one. const sample = { - id: "dev.lithe.plugin.sample-support", - tagPrefix: "plugin-sample-support-v", + id: "dev.lithe.plugin.sample-support", tagPrefix: "plugin-sample-support-v", packageDirectory: "Plugins/mac/Official/SampleSupport", - assetName: "Lithe-Sample-Support-{version}-darwin.zip", - platforms: ["darwin-arm64", "darwin-x64"], - index: { - name: "Sample Support", - author: "Lithe", - repository: "https://github.com/Lithe-IDEA/Lithe-IDEA", - description: "Sample official plugin.", - tags: ["language"], - license: "Apache-2.0", - }, -}; -const plugins = [sample]; -const readManifest = (directory) => { - assert.equal(directory, sample.packageDirectory); - return { id: sample.id, version: "1.0.0" }; + assets: [ + { assetName: "Sample-{version}-arm64.zip", platforms: ["darwin-arm64"], arch: "arm64" }, + { assetName: "Sample-{version}-x64.zip", platforms: ["darwin-x64"], arch: "x86_64" }, + ], + inputPaths: ["third_party/sample/manifest.json"], + index: { name: "Sample", author: "Lithe", repository: "https://github.com/example/ide", description: "Sample official plugin", license: "MIT" }, }; +const manifest = { id: sample.id, version: "1.1.0", pluginAPIVersion: 3, requires: { languages: 1, packageTrust: 1 } }; +const readManifest = () => structuredClone(manifest); +const empty = () => ({ schemaVersion: 1, plugins: [] }); +const checksumsFor = (release) => Object.fromEntries(release.assets.map((asset, i) => [asset.assetName, String(i + 1).repeat(64)])); +const list = (index, release) => updateIndex(index, release, { checksums: checksumsFor(release), releaseDate: "2026-10-25" }); -test("the repository configuration currently lists no downloadable official plugin", () => { - assert.deepEqual(loadConfig(), []); - assert.deepEqual(plan(), []); - assert.deepEqual(check(), []); - assert.deepEqual(pendingIndex(), []); +test("Java release inputs are configured but automatic publication waits for host rollout", () => { + const config = loadConfig(); + const java = config.find((entry) => entry.id === "dev.lithe.plugin.java-support"); + assert.equal(java.releaseEnabled, false); + assert.deepEqual(releaseFor(java).assets.map((asset) => asset.platforms), [["darwin-arm64"], ["darwin-x64"]]); + assert.deepEqual(plan(config, () => false), []); + assert.deepEqual(pendingIndex(config, empty(), () => ["plugin-java-support-v1.1.0"], () => { + throw new Error("A paused plugin must not be indexed even if a tag exists"); + }), []); }); -test("each official plugin has its own tag series", () => { +test("plans carry the actual API and capability metadata plus every architecture", () => { const release = releaseFor(sample, readManifest); - assert.equal(release.tag, `plugin-sample-support-v${release.version}`); - assert.equal(release.assetName, `Lithe-Sample-Support-${release.version}-darwin.zip`); - assert.throws(() => releaseFor(sample, () => ({ id: "dev.lithe.plugin.other", version: "1.0.0" }))); - assert.throws(() => releaseFor(sample, () => ({ id: sample.id, version: "1.0" }))); + assert.equal(release.pluginAPIVersion, 3); + assert.deepEqual(release.requires, manifest.requires); + assert.equal(release.tag, "plugin-sample-support-v1.1.0"); + assert.deepEqual(release.assets.map((asset) => asset.assetName), ["Sample-1.1.0-arm64.zip", "Sample-1.1.0-x64.zip"]); + assert.deepEqual(plan([sample], () => true, readManifest), []); + assert.equal(plan([sample], () => false, readManifest).length, 1); +}); + +test("rejects inconsistent manifest, overlapping platforms, assets and missing capability metadata", () => { + assert.throws(() => releaseFor(sample, () => ({ ...manifest, id: "wrong" }))); + assert.throws(() => releaseFor(sample, () => ({ ...manifest, version: "1.0" }))); + assert.throws(() => releaseFor(sample, () => ({ ...manifest, requires: undefined }))); + assert.throws(() => releaseFor({ ...sample, assets: [sample.assets[0], sample.assets[0]] }, readManifest)); + assert.throws(() => releaseFor({ ...sample, assets: [{ ...sample.assets[0], assetName: "../bad.zip" }] }, readManifest)); + assert.throws(() => releaseFor({ ...sample, assets: [{ ...sample.assets[0], platforms: [] }] }, readManifest)); + assert.throws(() => releaseFor({ ...sample, assets: [{ ...sample.assets[0], arch: "x86_64" }] }, readManifest)); }); -test("only untagged versions are planned", () => { - assert.equal(plan(plugins, () => true, readManifest).length, 0); - assert.deepEqual(plan(plugins, () => false, readManifest).map((release) => release.id), [sample.id]); +test("released plugin resources and shared upstream inputs require a version bump", () => { + assert.deepEqual(check([sample], () => true, () => false, readManifest), []); + assert.equal(check([sample], () => true, (_tag, paths) => { + assert.deepEqual(paths, [sample.packageDirectory, ...sample.inputPaths]); return true; + }, readManifest).length, 1); + assert.deepEqual(check([sample], () => false, () => true, readManifest), []); }); -test("changing a released plugin without a new version fails", () => { - assert.deepEqual(check(plugins, () => true, () => false, readManifest), []); - assert.equal(check(plugins, () => true, () => true, readManifest).length, plugins.length); - // A version that was never tagged is released on merge instead. - assert.deepEqual(check(plugins, () => false, () => true, readManifest), []); +test("index lists all architectures with actual manifest metadata and no partial version", () => { + const release = releaseFor(sample, readManifest); + const index = list(empty(), release); + const version = index.plugins[0].versions[0]; + assert.equal(version.pluginAPIVersion, 3); + assert.deepEqual(version.requires, manifest.requires); + assert.equal(version.downloads.length, 2); + assert.equal(version.downloads[1].downloadURL, "https://github.com/example/ide/releases/download/plugin-sample-support-v1.1.0/Sample-1.1.0-x64.zip"); + assert.equal(version.downloads[1].sha256, "2".repeat(64)); + for (const checksums of [{}, { [release.assets[0].assetName]: "1".repeat(64) }, { ...checksumsFor(release), extra: "3".repeat(64) }, { ...checksumsFor(release), [release.assets[0].assetName]: "ABC" }]) { + assert.throws(() => updateIndex(index, release, { checksums, releaseDate: "2026-10-25" })); + } + assert.deepEqual(empty(), { schemaVersion: 1, plugins: [] }); }); -test("index update keeps versions newest first and points at the tag asset", () => { - const sha = "a".repeat(64); - let index = { schemaVersion: 1, plugins: [] }; - index = updateIndex(index, sample, { version: "1.0.0", sha256: sha, releaseDate: "2026-10-12" }); - index = updateIndex(index, sample, { version: "1.2.0", sha256: sha, releaseDate: "2026-11-01" }); - index = updateIndex(index, sample, { version: "1.1.0", sha256: sha, releaseDate: "2026-10-20" }); - const entry = index.plugins[0]; - assert.deepEqual(entry.versions.map((version) => version.version), ["1.2.0", "1.1.0", "1.0.0"]); - assert.equal( - entry.versions[0].downloads[0].downloadURL, - "https://github.com/Lithe-IDEA/Lithe-IDEA/releases/download/plugin-sample-support-v1.2.0/Lithe-Sample-Support-1.2.0-darwin.zip", - ); - assert.deepEqual(entry.versions[0].downloads[0].platforms, ["darwin-arm64", "darwin-x64"]); - assert.throws(() => updateIndex(index, sample, { version: "1.3.0", sha256: "ABC", releaseDate: "2026-11-02" })); +test("index retry uses old tagged API, requirements and asset names after a source upgrade", () => { + const old = releaseFor({ ...sample, assets: [{ assetName: "Original-{version}.zip", platforms: ["darwin-arm64", "darwin-x64"] }] }, () => ({ id: sample.id, version: "1.0.0", pluginAPIVersion: 1 })); + const current = releaseFor(sample, readManifest); + const readTagged = (_plugin, tag) => tag.endsWith("1.0.0") ? old : current; + const tags = () => [old.tag, current.tag, `${sample.tagPrefix}not-a-version`]; + const pending = pendingIndex([sample], empty(), tags, readTagged); + assert.deepEqual(pending, [old, current]); + const oldIndex = list(empty(), pending[0]); + assert.equal(oldIndex.plugins[0].versions[0].pluginAPIVersion, 1); + assert.equal(oldIndex.plugins[0].versions[0].requires, undefined); + assert.match(oldIndex.plugins[0].versions[0].downloads[0].downloadURL, /Original-1.0.0.zip$/); + assert.deepEqual(pendingIndex([sample], oldIndex, tags, readTagged), [current]); + const complete = list(oldIndex, current); + assert.deepEqual(pendingIndex([sample], complete, tags, readTagged), []); + assert.deepEqual(complete.plugins[0].versions.map((version) => version.version), ["1.1.0", "1.0.0"]); }); -test("tagged versions missing from the index are pending, so a failed listing is retried", () => { - const sha = "a".repeat(64); - const tags = (prefix) => (prefix === sample.tagPrefix - ? [`${prefix}1.0.0`, `${prefix}1.1.0`, `${prefix}not-a-version`] - : []); - // Nothing listed yet: every released version is pending. - assert.deepEqual( - pendingIndex(plugins, { schemaVersion: 1, plugins: [] }, tags).map((release) => release.tag), - [`${sample.tagPrefix}1.0.0`, `${sample.tagPrefix}1.1.0`], - ); - // 1.0.0 was listed but 1.1.0's index pull request failed: only 1.1.0 is - // retried, with the asset name of that version. - const index = updateIndex({ schemaVersion: 1, plugins: [] }, sample, { version: "1.0.0", sha256: sha, releaseDate: "2026-10-12" }); - const pending = pendingIndex(plugins, index, tags); - assert.deepEqual(pending.map((release) => release.version), ["1.1.0"]); - assert.equal(pending[0].assetName, "Lithe-Sample-Support-1.1.0-darwin.zip"); - // Once listed, nothing is pending. - const listed = updateIndex(index, sample, { version: "1.1.0", sha256: sha, releaseDate: "2026-10-20" }); - assert.deepEqual(pendingIndex(plugins, listed, tags), []); +test("refuses a tag whose package version disagrees rather than listing another release", () => { + assert.throws(() => pendingIndex([sample], empty(), () => [`${sample.tagPrefix}1.0.0`], () => releaseFor(sample, readManifest))); }); diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index 5f0747da99..a40329c0d9 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -389,6 +389,8 @@ async function main() { // Includes agent-ui-test-evidence: fixture screenshots belong to one test // revision/theme/viewport, never to a reusable build or runtime resource. // Includes plugin-ui-icons: signed runtime packages are never build-cache inputs. + // official-plugin-packages also rejects dist/packages, dist/verify, per-architecture + // release ZIPs and java-plugin-distribution probes; origin trust is not transferable. // Includes agent-hook-library-state: user commands, native ownership journals and locks have no copy stage. // Includes agent-instruction-library-state: user text and sync locks have no build identity or copy stage. // Includes mcp-tool-probe-state: SDK sessions, native tool exclusions and diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index 0bdabe9e56..a08ac7e650 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -239,6 +239,16 @@ try { assert.notEqual(refused.status, 0); assert.match(diagnostics(refused), /plugin-web-state.*cannot be reused/); }); + await test("architecture release archives and extracted signature probes are never reusable", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const packages = registry.excludedResources.find(item => item.id === "official-plugin-packages"); + assert.ok(packages.locations.includes("dist/packages///")); + assert.ok(packages.locations.includes("dist/verify///")); + assert.ok(packages.locations.includes(".artifacts/java-plugin-distribution/")); + const refused = reuse(["--resource", packages.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /official-plugin-packages is isolated.*cannot be reused/); + }); await test("plugin packages and marketplace state stay isolated in each worktree", { timeout: 15000 }, () => { const listed = run(process.execPath, [reuseScript, "--list"]); assertSucceeded(listed); diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 89ea9c4c36..c386dc47e0 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -125,10 +125,14 @@ ".build///OfficialPlugins", "/Lithe/Plugins//", "Lithe.app/Contents/Resources/OfficialPlugins/dev.lithe.plugin.java-support/", - "/Lithe/PluginState//" - ], - "identity": "Assembled plugin packages carry build-time downloads (JDT LS, its extensions and one JDK per target architecture for Java Support) and an optional publisher signature; no reliable worktree identity stamp. Plugin state directories are mutable language-server state of one installation", - "reason": "Assemble each official declarative plugin independently in every worktree and validate it with LitheOfficialPluginVerifier; do not copy or symlink it across worktrees. Java Support is assembled from the reusable jdtls and jdk download caches by Plugins/mac/Official/JavaSupport/assemble-package.sh, so reuse those caches rather than the package. Never copy or share plugin state at any stage." + "/Lithe/PluginState//", + "dist/packages///", + "dist/verify///", + "dist/Lithe-Java-Support--darwin-.zip", + ".artifacts/java-plugin-distribution/" + ], + "identity": "Assembled plugin packages carry build-time downloads (JDT LS, its extensions and one JDK per target architecture for Java Support) and an optional publisher signature; no reliable worktree identity stamp. Plugin state directories are mutable language-server state of one installation. Independent release archives bind plugin version, darwin architecture, upstream input hashes, API/requires and final publisher signature; host-sealed bundled copies are a different origin, never a reusable package", + "reason": "Assemble each official declarative plugin independently in every worktree and validate it with LitheOfficialPluginVerifier; do not copy or symlink it across worktrees. Java Support is assembled from the reusable jdtls and jdk download caches by Plugins/mac/Official/JavaSupport/assemble-package.sh, so reuse those caches rather than the package. Never copy or share plugin state at any stage. Release verification extracts a separate copy and checks its publisher signature; delete per-run dist/verify and local integration output when no longer needed. Never transfer a sealed-origin exception to user-installed copies. Runtime installation versions stay read-only, preserving app signatures and Sparkle delta baselines." }, { "id": "language-tools", diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 3cf6a4b511..2c5dde7d28 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -399,6 +399,7 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | Capability | Covers | Versions | | --- | --- | --- | | `languages` | `languages[]` | `1` | +| `packageTrust` | `vendor.bundledSignatureRequirement`: origin-scoped trust for a host-sealed bundled copy of a publisher-signed plugin | `1` | | `languageServers` | `languageServers[]` apart from their launch fields | `1`, `2` (adds [`workspaceConfiguration`](#workspace-configuration-languageservers-2)) | | `serverProcess` | The launch fields of language servers, of build providers with a process of their own, of VCS providers, and of the plugin backend: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | | `executionProviders` | `executionProviders[]` | `1` | @@ -529,6 +530,20 @@ plugin only permits activation; nothing starts until its event occurs. A signature document, when present, must still be verified; community trust cannot silently ignore an invalid signature. +API level 3 packages can declare `requires.packageTrust: 1` and +`vendor.bundledSignatureRequirement: "sameTeamAsHost"`, together with +`vendor.signatureRequirement: "publisherPackage"`. Only official-namespace IDs +may use this declaration. Both the primary and bundled policies must match the +host's pinned official catalog. Only a package whose actual resolved path is +inside the host's sealed installation and whose origin is `bundled` may omit +the publisher signature. Every independently installed, imported or rolled-back +copy still needs the publisher signature. A present signature is always checked, +even inside the host seal. The optional declaration alone grants no trust. +Hosts without `packageTrust: 1` filter this version before download and reject +its manifest; no new application-version field is needed. An older manifest +without the declaration keeps its existing policy. The valid example is +`fixtures/publisher-bundled.plugin.json`. + A host pins the signature requirement of its official plugin IDs so a package cannot downgrade itself. Selecting a file from disk never overrides that pin, namespace restrictions, publisher-key checks, or the installation seal required diff --git a/shared/contracts/plugins/v1/fixtures/publisher-bundled.plugin.json b/shared/contracts/plugins/v1/fixtures/publisher-bundled.plugin.json new file mode 100644 index 0000000000..220924b2dc --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/publisher-bundled.plugin.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.publisher-bundled", + "displayName": "Publisher and bundled trust fixture", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": {"languages": 1, "languageServers": 1, "serverProcess": 1, "packageTrust": 1}, + "vendor": { + "id": "dev.lithe", + "displayName": "Lithe", + "signatureRequirement": "publisherPackage", + "bundledSignatureRequirement": "sameTeamAsHost" + }, + "permissions": ["languageServer.launch"], + "activationEvents": ["onLanguage:fixture"], + "languages": [{"id": "fixture", "displayName": "Fixture", "fileExtensions": ["fixture"]}], + "languageServers": [{ + "languageID": "fixture", + "launch": {"kind": "runtime", "tool": "java", "packagedRuntime": "runtime/${arch}/bin/java", "entry": "server.jar"}, + "arguments": [] + }] +} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index e210b478d8..5dd17efd54 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -28,6 +28,7 @@ "executionProviders": { "enum": [1] }, "languageServers": { "enum": [1] }, "languages": { "enum": [1] }, + "packageTrust": { "description": "Required exactly when vendor.bundledSignatureRequirement is present.", "enum": [1] }, "projectGenerators": { "description": "Entries of the New Project window: fields, requirements, and steps.", "enum": [1] }, "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, of VCS providers, and of the plugin backend.", "enum": [1] }, "settings": { "enum": [1] }, @@ -42,7 +43,11 @@ "properties": { "id": { "type": "string", "minLength": 1 }, "displayName": { "type": "string", "minLength": 1 }, - "signatureRequirement": { "enum": ["sameTeamAsHost", "publisherPackage", "none"] } + "signatureRequirement": { "enum": ["sameTeamAsHost", "publisherPackage", "none"] }, + "bundledSignatureRequirement": { + "description": "packageTrust 1: host-pinned official packages may trust the host's sealed bundled copy; all other origins require publisherPackage.", + "const": "sameTeamAsHost" + } } }, "permissions": { diff --git a/shared/platform-feature-matrix/features/java-support-plugin.json b/shared/platform-feature-matrix/features/java-support-plugin.json index aad20b01e1..149c2f3d97 100644 --- a/shared/platform-feature-matrix/features/java-support-plugin.json +++ b/shared/platform-feature-matrix/features/java-support-plugin.json @@ -2,7 +2,7 @@ "id": "java-support-plugin", "area": "语言支持", "group": "Java", - "capability": "Java 语言服务器作为随应用发行的声明式插件:JDT LS、Lombok、调试与测试扩展及其 JDK 由插件包拥有,可卸载和恢复;运行、测试、调试仍由宿主内置实现提供,由插件在 jdt 集成的 capabilities 中声明", + "capability": "Java 语言服务器作为随应用发行的声明式插件:JDT LS、Lombok、调试与测试扩展及其 JDK 由插件包拥有,可卸载和恢复;运行、测试、调试仍由宿主内置实现提供,由插件在 jdt 集成的 capabilities 中声明;支持 packageTrust 1 的宿主按来源校验内置副本与发布者签名更新,安装时验证自带运行时和入口;双架构独立发布配置已准备但尚未启用上架", "macos": { "evidence": [ "Plugins/mac/Official/JavaSupport/plugin.json", @@ -15,7 +15,11 @@ "shared/contracts/plugins/v1/fixtures/language-host-integration.plugin.json", "macos/Sources/Lithe/Core/Language/PluginLanguageProviderCatalogSource.swift", "macos/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift", - "macos/Tests/LitheTests/RunWorkflowCoordinatorTests.swift" + "macos/Tests/LitheTests/RunWorkflowCoordinatorTests.swift", + "macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift", + "shared/contracts/plugins/v1/fixtures/publisher-bundled.plugin.json", + "scripts/official-plugin-releases.mjs", + "Plugins/marketplace/official-plugins.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -29,5 +33,5 @@ "verificationStatus": "not-applicable" }, "owner": "Java Support", - "verification": "macOS:全新安装后打开插件管理,确认 Java Support 已安装且默认启用;打开 Maven 项目并点开 .java 文件,确认语言服务器启动,补全、诊断、跳转到 JDK 与依赖 jar 中的类(jdt://)、Lombok 生成的方法、运行标记可用;在打包出的应用里逐项确认宿主能力:工具栏调试能停在断点并继续,测试面板列出并能运行 Java 测试,“当前文件”与 Java main 运行配置能启动(Spring Boot 等 Maven 配置走另一条路径,不能替代这一项);用 lsof 或进程参数确认 JDT LS 及其 java 进程来自 Lithe.app/Contents/Resources/OfficialPlugins/dev.lithe.plugin.java-support,且应用内不存在 Contents/Resources/LanguageServers。卸载 Java Support 并重启:打开 .java 文件时提示需要安装或恢复该插件,语法高亮、Maven 面板和其他语言不受影响,/Lithe/PluginState 下该插件的目录被删除,app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。点击恢复并重启后 Java 功能恢复。从旧版本升级:首次打开 Java 项目会重建一次索引,之后正常。Windows:JDT LS 与 JDK 仍随应用安装目录分发,尚未接入该插件契约。" + "verification": "macOS:全新安装后打开插件管理,确认 Java Support 已安装且默认启用;打开 Maven 项目并点开 .java 文件,确认语言服务器启动,补全、诊断、跳转到 JDK 与依赖 jar 中的类(jdt://)、Lombok 生成的方法、运行标记可用;在打包出的应用里逐项确认宿主能力:工具栏调试能停在断点并继续,测试面板列出并能运行 Java 测试,“当前文件”与 Java main 运行配置能启动(Spring Boot 等 Maven 配置走另一条路径,不能替代这一项);用 lsof 或进程参数确认 JDT LS 及其 java 进程来自 Lithe.app/Contents/Resources/OfficialPlugins/dev.lithe.plugin.java-support,且应用内不存在 Contents/Resources/LanguageServers。卸载 Java Support 并重启:打开 .java 文件时提示需要安装或恢复该插件,语法高亮、Maven 面板和其他语言不受影响,/Lithe/PluginState 下该插件的目录被删除,app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。点击恢复并重启后 Java 功能恢复。从旧版本升级:首次打开 Java 项目会重建一次索引,之后正常。Windows:JDT LS 与 JDK 仍随应用安装目录分发,尚未接入该插件契约。 独立分发第一阶段:PluginBundledPublisherTrustTests 使用真实 Core 和注入测试公钥覆盖内置副本、已签名更新、用户目录伪造 bundled 来源、缺失运行时与篡改拒绝;官方发布索引回归验证双架构哈希与 tag 对应 API/requires。正式发布包、Intel 真机和完整产品下载迁移仍待验收,releaseEnabled 保持 false。" } From 6ab867aeb6ab6b08b6602f1de1ca53ba12b2e4a5 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Sun, 25 Oct 2026 14:44:34 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(plugins):=20=E5=AE=8C=E6=88=90=20Java?= =?UTF-8?q?=20Support=20=E8=BF=81=E7=A7=BB=E4=B8=8E=E6=8C=89=E9=9C=80?= =?UTF-8?q?=E5=88=86=E5=8F=91=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...09-30-lsp-plugin-build-and-distribution.md | 2 +- ...a-language-server-as-declarative-plugin.md | 6 +- ...-25-java-plugin-distribution-foundation.md | 22 ++- ...4-java-support-independent-distribution.md | 4 +- .github/workflows/ci-macos.yml | 1 + .../workflows/release-official-plugins.yml | 4 +- Plugins/mac/README.md | 12 +- Plugins/marketplace/official-plugins.json | 1 + docs/ci-builds.md | 17 +- macos/Resources/en.lproj/Localizable.strings | 4 + .../zh-Hans.lproj/Localizable.strings | 4 + .../WorkspaceProjectionComposition.swift | 1 + .../Features/JavaPluginTransition.swift | 18 ++ .../Features/NewProjectFeatureModel.swift | 3 + .../Features/PluginMarketplace.swift | 18 +- .../Models/AppModel/AppModel+JavaPlugin.swift | 21 +++ .../Lithe/Models/AppModel/AppModel.swift | 3 + .../MacOS/Plugins/MacPluginManager.swift | 34 +++- .../MacOS/Plugins/MacPluginPackageStore.swift | 6 + .../Views/Workspace/NewProjectView.swift | 14 ++ .../Catalog/BuiltInModuleCatalog.swift | 2 +- .../PluginBundledPublisherTrustTests.swift | 163 +++++++++++++++++- .../PluginMarketplaceFeatureModelTests.swift | 30 ++++ .../LitheTests/ProjectGenerationTests.swift | 12 ++ scripts/build-official-plugins.sh | 9 +- scripts/official-plugin-distribution.mjs | 51 ++++-- scripts/package-app.sh | 31 ++-- scripts/reuse-worktree-resources.mjs | 3 +- scripts/test-official-plugin-distribution.mjs | 79 ++++++++- scripts/test-reuse-worktree-resources.mjs | 2 + scripts/verify-macos-package.sh | 48 +++++- scripts/worktree-resources.json | 5 +- shared/i18n/locales/en-US.json | 4 +- shared/i18n/locales/zh-CN.json | 4 +- shared/i18n/messages.json | 22 +++ .../features/java-support-plugin.json | 9 +- 36 files changed, 605 insertions(+), 64 deletions(-) create mode 100644 macos/Sources/Lithe/Application/Features/JavaPluginTransition.swift create mode 100644 macos/Sources/Lithe/Models/AppModel/AppModel+JavaPlugin.swift diff --git a/.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md b/.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md index b10c0f7a21..b4d022f283 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md +++ b/.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md @@ -35,7 +35,7 @@ PHP Support 已经验证了独立插件包、固定 Intelephense 版本和插件 插件包不编译、不链接任何 Swift 代码,也不做 codesign,因此与架构和宿主构建无关。 -唯一的例外是自带运行时的 Java Support:它的包里有每个架构各一份的 JDK,因此与架构有关,当前仍随应用发行,内置副本由应用的代码签名覆盖;独立安装副本要求完整包的发布者签名。双架构发布输入已准备,自动上架仍暂停,见[按来源校验与独立发布基础](2026-10-25-java-plugin-distribution-foundation.md)。它的组装步骤在插件自己的目录里,见 [Java 语言服务器是一个声明式插件](2026-10-13-java-language-server-as-declarative-plugin.md)。 +唯一的例外是自带运行时的 Java Support:它的包里有每个架构各一份的 JDK,因此与架构有关,当前仍随应用发行,内置副本由应用的代码签名覆盖;独立安装副本要求完整包的发布者签名。双架构发布输入与迁移已准备,官方索引完整上架后构建会省去内置资源;自动上架仍暂停,见[按来源校验与独立发布基础](2026-10-25-java-plugin-distribution-foundation.md)。它的组装步骤在插件自己的目录里,见 [Java 语言服务器是一个声明式插件](2026-10-13-java-language-server-as-declarative-plugin.md)。 下载和组装逻辑放在插件专属的准备脚本里(PHP 迁出前是 `prepare-php-language-server.sh`,已随迁移删除)。后续插件可以复用相同的构建阶段和校验结构,但不能把某个语言的路径、版本或下载地址硬编码到宿主应用中。 diff --git a/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md b/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md index a8465bbfad..56d2893bc5 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md +++ b/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md @@ -4,7 +4,7 @@ ## 先说结论 -Java 的语言服务器 JDT LS(Eclipse 的 Java 语言服务器,提供补全、诊断和跳转)不再是 Lithe.app 里写死的一部分,而是官方插件 Java Support(`dev.lithe.plugin.java-support`)。插件包拥有 JDT LS、Lombok、调试和测试扩展,以及它运行所用的 JDK;`plugin.json` 声明这些文件在包内的位置和启动方式。这个插件随应用发行、默认启用,用户可以卸载和恢复。 +Java 的语言服务器 JDT LS(Eclipse 的 Java 语言服务器,提供补全、诊断和跳转)不再是 Lithe.app 里写死的一部分,而是官方插件 Java Support(`dev.lithe.plugin.java-support`)。插件包拥有 JDT LS、Lombok、调试和测试扩展,以及它运行所用的 JDK;`plugin.json` 声明这些文件在包内的位置和启动方式。过渡期随应用发行、默认启用,用户可以卸载和恢复;完整双架构官方下载上架后默认应用省去资源,迁移与安装引导由[后续分发决策](2026-10-25-java-plugin-distribution-foundation.md)规定。 宿主不再知道 JDT LS 或 JDK 放在哪里。它只做两件事:按插件的声明启动语言服务器;为声明了 `jdt` 宿主集成(由宿主实现、插件按名字启用的一段专用逻辑)的服务器启用 Java 的工程模型、`jdt://` 虚拟文档、测试和调试支持。 @@ -104,9 +104,9 @@ dev.lithe.plugin.java-support/ `LitheOfficialPluginVerifier` 的规则随之调整:Mach-O 原生代码只允许出现在清单 `packagedRuntime` 声明的顶层目录内;包内任何位置都不允许符号链接。 -### 5. 随应用发行、默认启用、可卸载 +### 5. 过渡期内置与独立安装 -- **随应用发行**:Java 是主场景,首次启动必须离线可用,所以包在 `Lithe.app/Contents/Resources/OfficialPlugins/` 下,清单主签名要求为 `publisherPackage`,内置副本通过 `bundledSignatureRequirement: sameTeamAsHost` 依靠应用自身的代码签名;只有真实位于宿主 bundle 内且来源为 bundled 的副本能用这个例外。按来源校验与独立发布基础见[后续决策](2026-10-25-java-plugin-distribution-foundation.md)。 +- **过渡期随应用发行**:原设计为 Java 保持首次启动离线可用,过渡包在 `Lithe.app/Contents/Resources/OfficialPlugins/` 下,清单主签名要求为 `publisherPackage`,内置副本通过 `bundledSignatureRequirement: sameTeamAsHost` 依靠应用自身的代码签名;只有真实位于宿主 bundle 内且来源为 bundled 的副本能用这个例外。按来源校验与独立发布基础见[后续决策](2026-10-25-java-plugin-distribution-foundation.md)。 - **默认启用**:随应用发行的包替代的是内置功能,它的模块默认开启;下载安装的插件,模块清单默认值仍是关闭,但用户在确认窗口里确认的首次安装(插件市场、社区源、磁盘包)会把它的模块记为启用,重启后直接生效,与 IntelliJ IDEA 安装即启用一致;更新和重装不改用户已有的启用选择。否则构建插件装好后 Build 工具窗口不出现,用户会以为界面没有注入。 - **卸载**:app bundle 运行时只读,包本身删不掉。卸载记录在用户目录的 `/Lithe/Plugins/.removed-bundled-plugins.json`,宿主重启后不再注册这个插件,并在那次启动时删除它的语言服务器状态(卸载后到重启前,语言服务器仍在运行并写入该目录,所以不在点击时删除;重启前恢复则不会丢失索引)。插件管理里仍然列出它并提供“恢复”。 - **卸载时 JDT 状态的清理**:JDT LS 的工作区索引(`/Lithe/language-servers/jdtls/`)和 Equinox 配置区(`jdtls-configuration/`)在宿主缓存里,不在插件自己的 `PluginState//` 下,插件包管理器删不到。所以由 `jdt` 集成自己清理:启动时 `pluginsDidLoad` 拿到扫描结果,如果没有任何“未卸载”的已安装插件声明 `jdt` 集成,就在后台删除这两个目录(不碰同目录下其他语言服务器的缓存)。扫描本身失败(拿不到已安装列表)、或插件已安装但启动时被拒绝,都不算卸载,状态保留。这样宿主既不用写死 Java Support 的插件 ID,也不需要把缓存搬进插件状态目录。 diff --git a/.agents/notes/implemented/architecture/2026-10-25-java-plugin-distribution-foundation.md b/.agents/notes/implemented/architecture/2026-10-25-java-plugin-distribution-foundation.md index d3c0ae6e56..bddd09af05 100644 --- a/.agents/notes/implemented/architecture/2026-10-25-java-plugin-distribution-foundation.md +++ b/.agents/notes/implemented/architecture/2026-10-25-java-plugin-distribution-foundation.md @@ -4,7 +4,7 @@ ## 先说结论 -Java Support 的同一份清单现在区分两种来源:随应用发行的副本可以依靠宿主签名,用户下载安装的副本必须有 Lithe 发布者签名。两种来源使用同一插件 ID,经过校验的用户安装版本替代内置版本,不重复登记 Java 语言服务。官方发布工具能记录 ARM64、Intel 两个归档及各自哈希,并从发布标签读取对应 API 和能力声明;自动上架仍暂停,真实发布包和用户迁移按[独立分发提案](../../proposed/architecture/2026-10-24-java-support-independent-distribution.md)继续验收。 +Java Support 的同一份清单现在区分两种来源:随应用发行的副本可以依靠宿主签名,用户下载安装的副本必须有 Lithe 发布者签名。两种来源使用同一插件 ID,经过校验的用户安装版本替代内置版本,不重复登记 Java 语言服务。官方发布工具能记录 ARM64、Intel 两个归档及各自哈希,并从发布标签读取对应 API 和能力声明;迁移保留启用选择并提供按需安装入口;主包只在完整双架构官方下载进入索引后移除资源。自动上架仍暂停,真实发布包按[独立分发提案](../../proposed/architecture/2026-10-24-java-support-independent-distribution.md)继续验收。 ## 问题 @@ -32,6 +32,22 @@ macOS 官方目录固定主签名和内置策略。包管理器先校验清单 JDT LS、JDK、Lombok、Debug 和 Test 扩展保持当前锁定版本。资源先组装,原生签名等会改字节的步骤完成后才生成发布者签名,最终归档解压后再验证。运行时启动继续复用现有 `jdt` 集成;安装检查新增对运行时启动的计划校验,拒绝缺失目标架构的 JDK、包内入口和相关固定资源,不运行 JDK、不创建验证状态目录。 +### 安装切换保留用户选择 + +内置清单默认启用,下载清单默认禁用。安装成功后,包管理器把原内置模块的默认值保存为显式选择,再在重启时选择下载版;显式禁用不变,安装失败不修改选择。迁移只读取旧包已经拥有的模块,新模块保持清单默认值,避免把新模块或其他所有者的状态误当作旧选择。同一 ID 的插件设置继续使用原键,项目 JDK、运行配置和调试的 JDK 选择链不变,不复制或改写用户配置。 + +旧 `.removed-bundled-plugins.json`(用户卸载内置包的记录)持续抑制内置来源。用户明确重新安装后,该记录不能清理已安装版本的状态;删除已安装更新时也记录内置移除,避免重启又启用被遮住的副本。跳过过渡宿主直接升级时,无包就保持未安装,显式禁用仍保留;只有用户确认安装才创建新版本,不自动下载。 + +打开 Java 文件或含 Java、Maven、Gradle 文件的项目时,无插件则显示每窗口会话一次的安装提示,支持前往市场和“从磁盘安装”的离线路径。新建项目页在 Java 插件缺失时提供模板安装入口,已有但禁用的插件继续走启用流程。离线或取消不重置设置,下一次会话可以重试。 + +### 精简应用以已发布索引为前提 + +发布登记中的 `bundledUntilPublishedVersion: 1.1.0` 表示安全退出内置分发的最低插件版本。构建读取本工作树的官方索引:至少一个达到版本门槛的版本,其仓库、API、能力要求与当前插件一致,两个架构的 URL、平台与 SHA-256 齐全且唯一,才不再组装内置 Java,也不预下载 JDT/JDK。索引缺项仍带过渡副本,索引读写格式损坏直接中止构建,不能当作“无需内置”。后续插件源码升版时,兼容的旧下载仍可满足门槛,不要求下载版本等于当前源码版本。 + +打包用同一份索引作判断并嵌入应用,运行时不联网决定是否删资源。`LITHE_OFFICIAL_PLUGIN_INDEX` 仅覆盖构建输入,传给插件构建的 `--marketplace-index`,默认读取仓库索引;测试用临时虚构下载项证明精简路径,不能将 fixture 发布为真实索引。预览的 `--bundled-only` 使用同一默认策略。公开下载前默认 app 仍内置 Java;公开包和索引独立验收合并后,下一次应用构建自然精简。 + +正确做法:先发布并验证签名双架构包,再合并真实索引;应用构建从该索引得出不再内置。不要这样做:因为发布配置写好了就先删资源,或运行时下载到 app bundle 中补回文件。 + ### 发布按标签固定全部输入 `official-plugins.json` 登记 Java 两种架构的归档和上游构建输入,`releaseEnabled: false` 保持暂停自动发布。先有包含新能力的宿主版本,再通过独立改动开放上架;本次不生成线上索引或发布标签。 @@ -57,13 +73,15 @@ JDT LS、JDK、Lombok、Debug 和 Test 扩展保持当前锁定版本。资源 收益是应用打包仍能独立完成,外部安装具有完整签名检查,Java 资源可以按架构独立发布。代价是首次下载版要求新宿主能力,签名策略不能只看一个枚举,还要核对官方目录、来源和真实路径。 -这一步交付分发基础。主包仍含 Java,索引未上架,老用户引导、启用选择迁移、正式发布签名包和完整产品下载验收尚未由本次改动证明;提案保持 `proposed`。 +代码覆盖可信安装、启用/卸载选择迁移、缺包引导和以发布索引为门槛的主包精简。当前索引未上架,所以主包仍含 Java;正式签名发布、真实市场下载及完整 Java 产品工作流尚未验收,提案保持 `proposed`,不能把测试 fixture 当作上线证据。 ## 验证 - `./scripts/verify-rust-core-comments.sh`、`./scripts/verify-shared-contracts.sh`:契约、注释和共享输入一致。 - Rust 的 `declarative_plugins` 与 `marketplace_plugins`:来源策略必须声明能力、非法组合拒绝、旧宿主在下载前拒绝。 - `PluginBundledPublisherTrustTests`:真实 Core、生产包管理器及注入测试公钥验证签名安装、单一来源、包篡改、伪造来源和缺失运行时,比较安装资源内容。 +- `./scripts/verify-macos-package.sh --slim`:以临时完整索引构建精简 universal app,拒绝 JDT/JDK 下载输入,检查资源缺失、签名和 DMG;默认模式检查保留过渡包。 +- `node --test scripts/test-official-plugin-distribution.mjs`:完整与部分上架、来源/能力不匹配和后续版本门槛。 - `node --test scripts/official-plugin-releases.test.mjs`:双架构索引、部分归档拒绝、历史标签元数据以及上游输入变更约束。 - `node scripts/test-reuse-worktree-resources.mjs`、`./scripts/verify-runtime-bundle-immutability.sh`:生成资源拒绝复用、发行目录只读边界。 - `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` 和 macOS 计时入口:相关测试必须有单项时长与 HTML/JUnit 结果。 diff --git a/.agents/notes/proposed/architecture/2026-10-24-java-support-independent-distribution.md b/.agents/notes/proposed/architecture/2026-10-24-java-support-independent-distribution.md index 0fd7c3d59f..c55fa1c190 100644 --- a/.agents/notes/proposed/architecture/2026-10-24-java-support-independent-distribution.md +++ b/.agents/notes/proposed/architecture/2026-10-24-java-support-independent-distribution.md @@ -121,7 +121,7 @@ Windows 插件包使用自己的原生运行时、入口和存储适配器,复 ### 八、分期与退出条件 -第一阶段的实现边界见[Java 插件按来源校验与独立发布基础](../../implemented/architecture/2026-10-25-java-plugin-distribution-foundation.md)。该实现登记双架构发布输入但保留 `releaseEnabled: false`,尚未向用户公开下载;过渡宿主发行、真实发布包验收和后续迁移仍按本提案推进。 +当前代码的实现边界见[Java 插件按来源校验与独立发布基础](../../implemented/architecture/2026-10-25-java-plugin-distribution-foundation.md)。该实现登记双架构发布输入但保留 `releaseEnabled: false`,尚未向用户公开下载。迁移选择、缺包提示及以完整索引为门槛的精简构建已在同一实现 PR 中补齐,过渡宿主发行、真实发布包和完整产品迁移仍按本提案验收。 | 阶段 | 交付内容 | 进入下一阶段前必须证明 | @@ -133,7 +133,7 @@ Windows 插件包使用自己的原生运行时、入口和存储适配器,复 | 后续:独立仓库 | 可选迁移插件源码、专属测试和发布来源,固定宿主测试资源输入 | 无第二份可编辑源码;用户无需重新安装或重置设置 | | 后续:Windows 对齐 | 补齐必需宿主能力、平台包和真实 Windows 回归 | 同一版本能力声明可执行,Windows 安装目录哈希不变 | -首期按三个实现 PR 推进:可信下载安装、老用户切换、移除内置资源,阶段 0 的基线记录随第一项交付。完成阶段 3 即可验收 macOS 独立分发,不等待独立仓库或 Windows;Windows 另行验收前不声明双端完成。每项实现从最新 `preview` 创建独立分支,本地验证后先提交 Draft PR;不通过反复推送试探完整 CI。 +按用户要求,可信下载安装、老用户切换与受发布索引保护的精简构建在同一个实现 PR 中交付,阶段 0 的基线记录一并保留。完成阶段 3 即可验收 macOS 独立分发,不等待独立仓库或 Windows;Windows 另行验收前不声明双端完成。实现分支从最新 `preview` 创建,本地验证后先提交 Draft PR;不通过反复推送试探完整 CI。 ### 正确做法与反例 diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 48c4140218..182d03a925 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -479,6 +479,7 @@ jobs: LITHE_DMG_DIAGNOSTICS: "1" run: | ./scripts/verify-macos-package.sh + ./scripts/verify-macos-package.sh --slim - name: Verify Sparkle update packaging run: | diff --git a/.github/workflows/release-official-plugins.yml b/.github/workflows/release-official-plugins.yml index 14b3e4ee8e..226a579522 100644 --- a/.github/workflows/release-official-plugins.yml +++ b/.github/workflows/release-official-plugins.yml @@ -15,6 +15,8 @@ on: - "Plugins/marketplace/**" - "scripts/official-plugin-releases.mjs" - "scripts/official-plugin-releases.test.mjs" + - "scripts/official-plugin-distribution.mjs" + - "scripts/test-official-plugin-distribution.mjs" - "scripts/build-official-plugins.sh" - "scripts/prepare-jdtls.sh" - "scripts/prepare-jdk.sh" @@ -57,7 +59,7 @@ jobs: fetch-tags: true - name: Test release planning - run: node --test scripts/official-plugin-releases.test.mjs + run: node --test scripts/official-plugin-releases.test.mjs scripts/test-official-plugin-distribution.mjs - name: Require a new version for changed plugins run: node scripts/official-plugin-releases.mjs check diff --git a/Plugins/mac/README.md b/Plugins/mac/README.md index a619fe4e70..d393edc54a 100644 --- a/Plugins/mac/README.md +++ b/Plugins/mac/README.md @@ -11,7 +11,7 @@ does not need to be rebuilt for each Lithe release; compatibility is decided by | Package | Distribution | Language server | | --- | --- | --- | -| `JavaSupport` | Bundled in Lithe.app, same Team ID as the host | JDT LS packaged with Lithe; New Project entries for Java, Maven Archetype, and Spring Boot ([project generators](../../shared/contracts/plugins/v1/README.md#project-generators)) | +| `JavaSupport` | Publisher-signed download; sealed transition copy until both architectures are listed | JDT LS packaged with Lithe; New Project entries for Java, Maven Archetype, and Spring Boot ([project generators](../../shared/contracts/plugins/v1/README.md#project-generators)) | ## Go and PHP moved to Lithe Store @@ -105,3 +105,13 @@ require a publisher signature, while the explicitly pinned bundled copy can rely on the host application's seal. `--bundled-only` selects that application build; an ordinary standalone release build must carry the publisher signature. Existing hosts without this capability refuse the new version before download. + +Java publication remains paused (`releaseEnabled: false`). App assembly retains +the transition copy until the checked-in official index lists a compatible +release at or above `bundledUntilPublishedVersion` with both architecture +archives. Then `--bundled-only` omits Java and app packaging skips its downloads. +The same index is embedded in the app. `LITHE_OFFICIAL_PLUGIN_INDEX` (app build) +and `--marketplace-index` (plugin build) select an explicit read-only build input; +test fixture indexes must never be published. Installation preserves saved +choices and settings; missing Java support has Marketplace and offline disk +installation entry points. diff --git a/Plugins/marketplace/official-plugins.json b/Plugins/marketplace/official-plugins.json index 2eebe59bcb..ba5d5db351 100644 --- a/Plugins/marketplace/official-plugins.json +++ b/Plugins/marketplace/official-plugins.json @@ -5,6 +5,7 @@ { "id": "dev.lithe.plugin.java-support", "releaseEnabled": false, + "bundledUntilPublishedVersion": "1.1.0", "tagPrefix": "plugin-java-support-v", "packageDirectory": "Plugins/mac/Official/JavaSupport", "inputPaths": ["third_party/jdtls/manifest.json", "third_party/jdk/manifest.json", "scripts/prepare-jdtls.sh", "scripts/prepare-jdk.sh", "scripts/build-official-plugins.sh"], diff --git a/docs/ci-builds.md b/docs/ci-builds.md index f25b8174f9..5a99e82c84 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -684,13 +684,26 @@ Inter 4.1 的 18 个静态 OTF(内部版本 4.001)及许可、JetBrains Mono 可下载的官方插件包是声明式插件包(只含 `plugin.json` 和它声明的资源,不含宿主加载的代码,与架构无关;自带运行时的 Java Support 见下文),在 `.build///OfficialPlugins` 中独立组装,`--triple` 只决定输出目录和签名工具的构建布局;组装后通过 `LitheOfficialPluginVerifier` 验证声明、打包的可执行文件和“Mach-O 原生代码只出现在清单声明的运行时目录内”;发布配置还要求 repository secret `LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`,由 `LithePluginPackageSigner` 对完整包生成 `lithe-plugin-signature.json`,客户端按宿主内置官方插件策略和 publisher 公钥验证后才允许安装。该 secret 的值是与客户端内置公钥匹配的 base64 编码 32 字节 Ed25519 私钥,可用 `gh secret set LITHE_PLUGIN_PACKAGE_PRIVATE_KEY --repo 1lck/Lithe-IDEA < /secure/path/lithe-plugin-package-private-key.base64` 配置;私钥文件和 shell 历史都不得进入仓库或日志。没有该 key 时,普通 debug 全量构建会跳过 publisher 签名包,指定其中一个包 ID 的构建会失败,不会留下缺少签名文档的目录。本地调试插件时可以显式传入 `--development-unsigned`(仅 debug 配置可用),生成不含签名文档的开发包,只能由 debug 构建的 Lithe 从磁盘安装;这类开发包同样不跨工作树复用。无可靠 identity stamp,不跨工作树复制。插件安装后打包的语言服务器位于 `/Lithe/Plugins//versions//language-server`,由插件版本目录拥有,运行时只读,重装、回滚和卸载随插件一起处理,不是工作树构建缓存。Go Support 和 PHP Support 已迁移为 Lithe Store 社区插件(`Lithe-IDEA/lithe-plugin-go`、`Lithe-IDEA/lithe-plugin-php`),不再由本仓库构建。PHPUnit 测试夹具的 `shared/fixtures/phpunit-project/vendor` 也由当前工作树独立安装。以上项目在资源清单 `excludedResources` 中明确排除,复用脚本会拒绝显式复制请求。 -### Java Support 独立下载安装基础 +### Java Support 独立下载安装与迁移 Java Support 当前仍随应用发行,独立下载的构建配置已登记,但 `Plugins/marketplace/official-plugins.json` 中的 `releaseEnabled: false` 暂停自动发布。 等支持 `packageTrust: 1` 的宿主发行、迁移验收和真实下载包验证完成后,再单独启用发布; 不要在基础设施 PR 中提前上架旧宿主无法安装的包。 +`bundledUntilPublishedVersion: 1.1.0` 控制过渡副本的退出条件:只有仓库官方索引 +包含达到门槛且 API/能力匹配的完整双架构版本,应用才跳过 JDT/JDK 准备及 Java 插件组装。 +空索引或缺少某个架构仍内置;损坏索引会中止构建,不能静默省去插件。安装使用同一 ID, +启用、禁用、旧卸载标记及设置保留;打开 Java 文件/项目或新建项目时有明确安装入口, +离线可使用可信磁盘包。当前无线上发布,默认构建仍保留过渡包。 + +构建输入 `LITHE_OFFICIAL_PLUGIN_INDEX` 默认为 `Plugins/marketplace/index.json`,只读, +同时决定内置分发和应用内嵌索引;传给 `build-official-plugins.sh --marketplace-index`。 +没有下载或缓存步骤,不允许从其他工作树的生成索引或用户运行时索引复用。 +`verify-macos-package.sh --slim` 在本次临时目录生成双架构索引 fixture,并验证在缺少 +JDT/JDK 输入时仍能生成不含 Java 资源的 universal app 和 DMG;fixture 不是可发布索引, +随测试目录清理,不影响签名或 Sparkle 发行基线。 + 同一份清单要求下载版的 `publisherPackage` 签名,并声明内置副本的 `bundledSignatureRequirement: sameTeamAsHost`。应用组装必须显式用 `--bundled-only`, 由 app 自身签名封住完整包;普通单插件构建在正式配置中始终要求发布者私钥。 @@ -713,7 +726,7 @@ Java Support 当前仍随应用发行,独立下载的构建配置已登记, 临时验证副本由本次验证清理,发布 runner 的 `dist` 随 job 结束回收;持久安装的 版本包由宿主包管理器负责,运行时保持只读。它们不改变应用签名或 Sparkle 源基线。 -Java Support(`dev.lithe.plugin.java-support`)是唯一随 Lithe.app 发行的官方插件包, +Java Support(`dev.lithe.plugin.java-support`)是过渡期唯一随 Lithe.app 发行的官方插件包, 也是唯一自带运行时的包,因此它与架构有关: - **资源路径**:组装结果在 `.build///OfficialPlugins/dev.lithe.plugin.java-support/`, diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 9d93405b4c..7f504aa673 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1559,6 +1559,7 @@ "Install ACP adapters, check the runtime, and manage local or custom providers." = "Install ACP adapters, check the runtime, and manage local or custom providers."; "Install AutoBuild" = "Install AutoBuild"; "Install Codex CLI to discover its skills." = "Install Codex CLI to discover its skills."; +"Install Java Support for Java, Maven and Spring templates…" = "Install Java Support for Java, Maven and Spring templates…"; "Install Node.js and npm before installing the adapter." = "Install Node.js and npm before installing the adapter."; "Install PHP Support Plugin" = "Install PHP Support Plugin"; "Install PHP Support from a signed plugin package." = "Install PHP Support from a signed plugin package."; @@ -1621,6 +1622,7 @@ "Japanese characters" = "Japanese characters"; "Java Application" = "Java Application"; "Java SDK" = "Java SDK"; +"Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept." = "Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept."; "Java entries appear after the Java language service lists runnable classes." = "Java entries appear after the Java language service lists runnable classes."; "Java entries could not be refreshed" = "Java entries could not be refreshed"; "Java navigation is available for .java files" = "Java navigation is available for .java files"; @@ -7837,6 +7839,8 @@ "npm was not found. It is normally installed with Node.js." = "npm was not found. It is normally installed with Node.js."; "or" = "or"; "outline.expandAll" = "Expand All"; +"plugins.java.installNotice" = "Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept."; +"plugins.java.installTemplates" = "Install Java Support for Java, Maven and Spring templates…"; "plugins.title" = "Plugins"; "ports %@" = "ports %@"; "project Maven Wrapper" = "project Maven Wrapper"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index d19ad1ebb7..bb368a4c41 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1559,6 +1559,7 @@ "Install ACP adapters, check the runtime, and manage local or custom providers." = "安装 ACP 适配器、检查运行环境,管理本机配置或自定义供应商。"; "Install AutoBuild" = "安装 AutoBuild"; "Install Codex CLI to discover its skills." = "请先安装 Codex CLI,再查看它发现的技能。"; +"Install Java Support for Java, Maven and Spring templates…" = "安装 Java Support,获取 Java、Maven 和 Spring 项目模板…"; "Install Node.js and npm before installing the adapter." = "请先安装 Node.js 和 npm,再安装适配器。"; "Install PHP Support Plugin" = "安装 PHP Support 插件"; "Install PHP Support from a signed plugin package." = "请从已签名的插件包安装 PHP Support。"; @@ -1621,6 +1622,7 @@ "Japanese characters" = "日文字符"; "Java Application" = "Java 应用"; "Java SDK" = "Java SDK"; +"Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept." = "Java Support 现已独立安装。请从插件市场安装,或使用“从磁盘安装”导入离线包。原有 Java 设置会保留。"; "Java entries appear after the Java language service lists runnable classes." = "Java 语言服务列出可运行的类之后,Java 入口才会出现。"; "Java entries could not be refreshed" = "无法刷新 Java 入口"; "Java navigation is available for .java files" = "Java 导航仅适用于 .java 文件"; @@ -7837,6 +7839,8 @@ "npm was not found. It is normally installed with Node.js." = "未找到 npm。它通常随 Node.js 一起安装。"; "or" = "或"; "outline.expandAll" = "全部展开"; +"plugins.java.installNotice" = "Java Support 现已独立安装。请从插件市场安装,或使用“从磁盘安装”导入离线包。原有 Java 设置会保留。"; +"plugins.java.installTemplates" = "安装 Java Support,获取 Java、Maven 和 Spring 项目模板…"; "plugins.title" = "插件"; "ports %@" = "端口 %@"; "project Maven Wrapper" = "项目 Maven Wrapper"; diff --git a/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift b/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift index fa2107a67e..3aa544c134 100644 --- a/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift +++ b/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift @@ -81,6 +81,7 @@ enum WorkspaceProjectionComposition { }, onSnapshotLoaded: { [weak model] snapshot, isInitialLoad in guard let model else { return } + model.suggestJavaPluginIfNeeded(files: snapshot.files) // Loading a ready inventory also resumes deferred run actions. await model.loadProjectServices( at: snapshot.root.url, diff --git a/macos/Sources/Lithe/Application/Features/JavaPluginTransition.swift b/macos/Sources/Lithe/Application/Features/JavaPluginTransition.swift new file mode 100644 index 0000000000..bfa87f1cff --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/JavaPluginTransition.swift @@ -0,0 +1,18 @@ +import Foundation +import LitheModuleAPI + +// Note: Java 独立分发与迁移见 .agents/notes/implemented/architecture/2026-10-25-java-plugin-distribution-foundation.md + +/// Suggests installation only when Java is needed and the package is absent. +/// Disabled, removed and staged packages keep their existing management flow; +/// no suggestion installs a package or changes a saved enablement choice. +enum JavaPluginTransition { + static func shouldSuggest(files: [URL], installedPluginIDs: Set) -> Bool { + guard !installedPluginIDs.contains(OfficialPluginCatalog.javaPluginID), + let java = OfficialPluginCatalog.entry(for: OfficialPluginCatalog.javaPluginID) else { return false } + return files.contains { + java.fileExtensions.contains($0.pathExtension.lowercased()) + || java.projectFileNames.contains($0.lastPathComponent.lowercased()) + } + } +} diff --git a/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift b/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift index 76af7f6baa..66f9e15161 100644 --- a/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift @@ -56,6 +56,9 @@ final class NewProjectFeatureModel: ObservableObject { static let defaultLocation = "~/LitheProjects" @Published private(set) var entries: [ProjectGeneratorEntry] = [] + var isJavaSupportMissing: Bool { + !sources.contains { $0.pluginID == OfficialPluginCatalog.javaPluginID } + } @Published private(set) var catalogError: String? @Published var selection: Selection = .empty { didSet { if oldValue != selection { selectionChanged() } } diff --git a/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift b/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift index b7080e6716..0aeee105eb 100644 --- a/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift +++ b/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift @@ -492,9 +492,17 @@ final class PluginMarketplaceFeatureModel: ObservableObject { return .installedFromOtherSource(sourceName: name) } if let available = offer.entry.availability.download, - let availableVersion = PluginVersion(available.version), - availableVersion > snapshot.manifest.version { - return .updateAvailable(installed: installedVersion, available: available.version) + let availableVersion = PluginVersion(available.version) { + // A sealed transition copy can move to the independent + // distribution without changing the plugin version. This is + // still an explicit installation, never an automatic download. + if snapshot.origin == .bundled, snapshot.installationStatus == .removed { + return .installable + } + if availableVersion > snapshot.manifest.version + || (snapshot.origin == .bundled && availableVersion == snapshot.manifest.version) { + return .updateAvailable(installed: installedVersion, available: available.version) + } } return .installed(version: installedVersion) } @@ -548,6 +556,10 @@ final class PluginMarketplaceFeatureModel: ObservableObject { if snapshot.origin == .marketplace, official != nil { return PluginMarketplaceSource.officialID } + if snapshot.origin == .bundled, official?.distribution == .download, + official?.bundledSignatureRequirement == .sameTeamAsHost { + return PluginMarketplaceSource.officialID + } return nil } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+JavaPlugin.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaPlugin.swift new file mode 100644 index 0000000000..5232ca8ae0 --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaPlugin.swift @@ -0,0 +1,21 @@ +import Foundation +import LitheModuleAPI + +extension AppModel { + /// One notice per window session keeps offline failures retryable on the + /// next launch, without repeating it for each file in a Java project. + func suggestJavaPluginIfNeeded(files: [URL]) { + guard !hasSuggestedJavaPlugin, + JavaPluginTransition.shouldSuggest( + files: files, + installedPluginIDs: Set(services.pluginManager.snapshots.map(\.id)) + ) else { return } + hasSuggestedJavaPlugin = true + showNotification( + String(localized: "Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept."), + action: WorkbenchNotification.Action(title: String(localized: "Open Marketplace")) { [weak self] in + self?.showPluginMarketplace(searching: "Java Support") + } + ) + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 2cee9f6d94..b5d3a391f0 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -13,6 +13,7 @@ import LitheCoreContracts @MainActor final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { let id = UUID() + var hasSuggestedJavaPlugin = false lazy var debugPerformanceFeature = DebugPerformanceFeatureModel(sampler: services.debugPerformanceSampler) lazy var testPerformanceFeature = TestJVMPerformanceFeatureModel( sampler: services.debugPerformanceSampler, provider: services.testJVMPerformance) @@ -1054,6 +1055,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { displayPath: String? = nil, allowsOutsideWorkspace: Bool = false ) { + suggestJavaPluginIfNeeded(files: [url]) editorTabOrderFeature.repositoryDiffSelected = false selectedChange = nil closeBranchComparison() @@ -1071,6 +1073,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { } func openStandaloneFile(_ url: URL) { + suggestJavaPluginIfNeeded(files: [url]) workspaceSessionCoordinator.openStandaloneFile(at: url) } diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift index 2a3b47b7c7..60dd462b84 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift @@ -173,7 +173,7 @@ final class MacPluginManager: PluginManaging { marketplace: provenance, beforeReplacingPackage: beforePackageReplacement ) - enableFirstInstallation(of: installed.manifest) + preserveEnablementAfterInstallation(of: installed.manifest) restartRequiredPluginIDs.insert(installed.manifest.id) try refreshInstalledPlugins() } @@ -187,7 +187,7 @@ final class MacPluginManager: PluginManaging { disk: provenance, beforeReplacingPackage: beforePackageReplacement ) - enableFirstInstallation(of: installed.manifest) + preserveEnablementAfterInstallation(of: installed.manifest) restartRequiredPluginIDs.insert(installed.manifest.id) try refreshInstalledPlugins() } @@ -209,7 +209,7 @@ final class MacPluginManager: PluginManaging { origin: isDevelopmentPackage ? .localDevelopment : .marketplace, beforeReplacingPackage: beforePackageReplacement ) - enableFirstInstallation(of: installed.manifest) + preserveEnablementAfterInstallation(of: installed.manifest) restartRequiredPluginIDs.insert(installed.manifest.id) try refreshInstalledPlugins() } @@ -228,6 +228,9 @@ final class MacPluginManager: PluginManaging { throw PluginManagerError.unknownPlugin(pluginID) } beforePackageReplacement(pluginID) + if OfficialPluginCatalog.entry(for: pluginID)?.bundledSignatureRequirement == .sameTeamAsHost { + try packageStore.setBundledPluginRemoved(pluginID, removed: true) + } try packageStore.stageInvalidPackageUninstall(pluginID) restartRequiredPluginIDs.insert(pluginID) issues.removeAll { $0.pluginID == pluginID } @@ -257,6 +260,11 @@ final class MacPluginManager: PluginManaging { // The package itself lives in the read-only app bundle. try packageStore.setBundledPluginRemoved(pluginID, removed: true) } else { + // An installed update can shadow a sealed copy in a transition + // host. Uninstalling it must not resurrect that copy at restart. + if installed.manifest.vendor.bundledSignatureRequirement == .sameTeamAsHost { + try packageStore.setBundledPluginRemoved(pluginID, removed: true) + } try packageStore.stageUninstall(pluginID) } restartRequiredPluginIDs.insert(pluginID) @@ -277,8 +285,24 @@ final class MacPluginManager: PluginManaging { /// The user confirmed this installation, so its modules start enabled at /// the next launch. Module defaults stay off for packages that appear /// without that confirmation, and an update keeps the user's choice. - private func enableFirstInstallation(of manifest: PluginManifest) { - guard installedPlugins[manifest.id] == nil, !registeredPluginIDs.contains(manifest.id) else { return } + private func preserveEnablementAfterInstallation(of manifest: PluginManifest) { + if let existing = installedPlugins[manifest.id] { + // Bundled modules default on, while the same downloaded declaration + // defaults off. Materialize the old default only after installation + // succeeds. Explicit disabled choices and settings stay untouched. + if existing.installation.origin == .bundled { + let oldModules = Dictionary(uniqueKeysWithValues: existing.manifest.modules.map { ($0.manifest.id, $0.manifest) }) + for declaration in manifest.modules where configurationStore.enabledState(for: declaration.manifest.id) == nil { + // Only modules the old package owned have a choice to + // migrate. New modules keep their declaration's default. + guard let previous = oldModules[declaration.manifest.id] else { continue } + let enabled = existing.installation.status == .removed || previous.defaultState == .enabled + configurationStore.setEnabledState(enabled, for: declaration.manifest.id) + } + } + return + } + guard !registeredPluginIDs.contains(manifest.id) else { return } // Language module IDs are shared across plugin IDs. A package rejected // for an ownership collision at restart must not first enable its owner. // Reserve disabled/removed packages too, and keep the running graph's diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift index 4eaaca65d1..d95e7e3fca 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift @@ -200,6 +200,12 @@ final class MacPluginPackageStore { // The server of a bundled plugin removed last session has stopped by // now, so its state can go without pulling files from under it. for pluginID in removedBundledPluginIDs() { + // A confirmed installation may replace a previously removed sealed + // copy. The removal marker still suppresses that fallback, but it + // must never erase the installed version's state or credentials. + let directory = rootURL.appendingPathComponent(pluginID.rawValue, isDirectory: true) + if let record = try? installationRecord(at: directory), + record.status == .installed || record.status == .updateStaged { continue } removeState(of: pluginID) } } diff --git a/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift b/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift index 64ed64a9fc..e63edfdd05 100644 --- a/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift +++ b/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift @@ -60,6 +60,20 @@ struct NewProjectView: View { get: { if case .generator(let id) = feature.selection { return "generator:" + id }; return "empty" }, set: { _ in })) Rectangle().fill(LitheTheme.divider).frame(height: 1) + if feature.isJavaSupportMissing { + Button { + feature.openMarketplace(searching: "Java Support") + } label: { + Text("Install Java Support for Java, Maven and Spring templates…") + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.link) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.litheNoPress) + .lithePointer() + .padding(.horizontal, 14) + .padding(.vertical, 10) + } Button { feature.openMarketplace(searching: "") } label: { diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index f295e10e4e..f39dfac2c7 100644 --- a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -244,7 +244,7 @@ public enum OfficialPluginCatalog { displayName: "Java Support", signatureRequirement: .publisherPackage, bundledSignatureRequirement: .sameTeamAsHost, - distribution: .bundled, + distribution: .download, languageID: "java", languageDisplayName: "Java", fileExtensions: ["java"], diff --git a/macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift b/macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift index 4df64d65a4..5351e28326 100644 --- a/macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift +++ b/macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift @@ -2,13 +2,142 @@ import CryptoKit import Foundation @testable import Lithe import LitheCoreContracts +import LitheApplicationKernel import LitheModuleAPI import LithePluginPackageSigning import Testing /// Runs production validation, signature verification and package storage. /// No fixture process executes, and all files belong to this test's root. +@MainActor struct PluginBundledPublisherTrustTests { + @Test(arguments: [true, false]) + func installingOverBundledCopyPreservesDefaultOrDisabledChoice(enabled: Bool) throws { + let fixture = try Fixture() + defer { fixture.remove() } + let preferences = MigrationPreferences() + let configuration = MacModuleConfigurationStore(store: preferences) + let store = fixture.store() + let initial = try store.scanInstalledPlugins().packages + let moduleID = try #require(initial.first?.manifest.modules.first?.manifest.id) + if !enabled { configuration.setEnabledState(false, for: moduleID) } + let settings = InMemoryPluginSettings([OfficialPluginCatalog.javaPluginID.rawValue: ["saved": .string("retained")]]) + let manager = fixture.manager(store: store, configuration: configuration, packages: initial, settings: settings) + let original = try fixture.digest(of: fixture.bundled) + try fixture.sign(fixture.download) + + try manager.installPackage(at: fixture.download) + + #expect(configuration.enabledState(for: moduleID) == enabled) + #expect(manager.snapshots.first?.isEnabled == enabled) + #expect(manager.snapshots.first?.requiresRestart == true) + #expect(settings.values(forPlugin: OfficialPluginCatalog.javaPluginID.rawValue)["saved"] == .string("retained")) + try store.prepareForLaunch() + let restarted = try store.scanInstalledPlugins() + #expect(restarted.packages.count == 1) + #expect(restarted.packages.first?.installation.origin == .marketplace) + #expect(restarted.packages.first?.installation.status == .installed) + #expect(try fixture.digest(of: fixture.bundled) == original) + } + + @Test + func migrationDoesNotEnableModulesThePreviousPackageDidNotOwn() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let store = fixture.store() + try store.setBundledPluginRemoved(OfficialPluginCatalog.javaPluginID, removed: true) + let configuration = MacModuleConfigurationStore(store: MigrationPreferences()) + let manager = fixture.manager(store: store, configuration: configuration, packages: try store.scanInstalledPlugins().packages) + var object = fixture.manifest + var languages = try #require(object["languages"] as? [[String: Any]]) + languages.append(["id": "future", "displayName": "Future", "fileExtensions": ["future"]]) + object["languages"] = languages + object["activationEvents"] = ["onLanguage:fixture", "onLanguage:future"] + var servers = try #require(object["languageServers"] as? [[String: Any]]) + var added = try #require(servers.first) + added["languageID"] = "future" + servers.append(added) + object["languageServers"] = servers + try JSONSerialization.data(withJSONObject: object).write(to: fixture.download.appendingPathComponent("plugin.json")) + try fixture.sign(fixture.download) + + try manager.installPackage(at: fixture.download) + + #expect(configuration.enabledState(for: .languageServerExtension("fixture")) == true) + #expect(configuration.enabledState(for: .languageServerExtension("future")) == nil) + } + + @Test + func directUpgradeWithoutBundledCopyKeepsDisabledPreferenceAndRemovedState() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let store = fixture.store(includeBundled: false) + try store.setBundledPluginRemoved(OfficialPluginCatalog.javaPluginID, removed: true) + try store.prepareForLaunch() + #expect(try store.scanInstalledPlugins().packages.isEmpty) + let configuration = MacModuleConfigurationStore(store: MigrationPreferences()) + let moduleID = try #require(DeclarativePluginTestSupport.operations.validate( + manifest: Data(contentsOf: fixture.download.appendingPathComponent("plugin.json")) + ).pluginManifest.modules.first?.manifest.id) + configuration.setEnabledState(false, for: moduleID) + let manager = fixture.manager(store: store, configuration: configuration, packages: []) + try fixture.sign(fixture.download) + try manager.installPackage(at: fixture.download) + // An explicit disk install is allowed, but does not turn a disabled + // module back on or let the old removal marker delete its state. + let state = fixture.stateDirectories.pluginDirectory(pluginID: OfficialPluginCatalog.javaPluginID.rawValue) + try FileManager.default.createDirectory(at: state, withIntermediateDirectories: true) + let saved = state.appendingPathComponent("saved.json") + try Data("user state".utf8).write(to: saved) + try store.prepareForLaunch() + #expect(configuration.enabledState(for: moduleID) == false) + #expect(try Data(contentsOf: saved) == Data("user state".utf8)) + #expect(try store.scanInstalledPlugins().packages.count == 1) + } + + @Test + func uninstallingInstalledUpdateDoesNotResurrectBundledJava() async throws { + let fixture = try Fixture() + defer { fixture.remove() } + let store = fixture.store() + try fixture.sign(fixture.download) + _ = try store.installPackage(from: fixture.download) + let configuration = MacModuleConfigurationStore(store: MigrationPreferences()) + let manager = fixture.manager(store: store, configuration: configuration, packages: try store.scanInstalledPlugins().packages) + try await manager.uninstall(OfficialPluginCatalog.javaPluginID) + try store.prepareForLaunch() + let packages = try store.scanInstalledPlugins().packages + #expect(packages.count == 1) + #expect(packages.first?.installation.origin == .bundled) + #expect(packages.first?.installation.status == .removed) + } + + @Test + func failedInstallPreservesBundledEnablementAndImmutableResources() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let configuration = MacModuleConfigurationStore(store: MigrationPreferences()) + let store = fixture.store() + let packages = try store.scanInstalledPlugins().packages + let manager = fixture.manager(store: store, configuration: configuration, packages: packages) + let moduleID = try #require(packages.first?.manifest.modules.first?.manifest.id) + let before = try fixture.digest(of: fixture.bundled) + #expect(throws: (any Error).self) { try manager.installPackage(at: fixture.download) } + #expect(configuration.enabledState(for: moduleID) == nil) + #expect(manager.snapshots.first?.isEnabled == true) + #expect(manager.snapshots.first?.requiresRestart == false) + #expect(try fixture.digest(of: fixture.bundled) == before) + } + + @Test + func missingJavaIsSuggestedOnlyWhenNeededAndNoPackageExists() { + for name in ["Main.java", "pom.xml", "build.gradle", "build.gradle.kts"] { + let files = [URL(fileURLWithPath: "/fixture/\(name)")] + #expect(JavaPluginTransition.shouldSuggest(files: files, installedPluginIDs: [])) + #expect(!JavaPluginTransition.shouldSuggest(files: files, installedPluginIDs: [OfficialPluginCatalog.javaPluginID])) + } + #expect(!JavaPluginTransition.shouldSuggest(files: [URL(fileURLWithPath: "/fixture/README.md")], installedPluginIDs: [])) + } @Test func hostSealedCopyAndSignedInstalledUpdateUseOnePluginIdentity() throws { let fixture = try Fixture() @@ -133,10 +262,13 @@ struct PluginBundledPublisherTrustTests { } } - func store() -> MacPluginPackageStore { + var stateDirectories: MacPluginStateDirectories { MacPluginStateDirectories(rootURL: root.appendingPathComponent("state")) } + + func store(includeBundled: Bool = true) -> MacPluginPackageStore { MacPluginPackageStore( rootURL: root.appendingPathComponent("installed"), - bundledRootURL: bundled.deletingLastPathComponent(), + bundledRootURL: includeBundled ? bundled.deletingLastPathComponent() : nil, + stateDirectories: stateDirectories, validator: DeclarativePluginTestSupport.operations, verifier: MacOfficialPluginSignatureVerifier( trustedPublisherKeys: [PluginPackageSignature.keyID: privateKey.publicKey], @@ -146,6 +278,24 @@ struct PluginBundledPublisherTrustTests { ) } + @MainActor + func manager( + store: MacPluginPackageStore, + configuration: MacModuleConfigurationStore, + packages: [InstalledPluginPackage], + settings: any PluginSettingsStoring = InMemoryPluginSettings() + ) -> MacPluginManager { + MacPluginManager( + packageStore: store, + moduleRuntime: ModuleRuntime(configurationStore: configuration, recoveryStore: configuration), + configurationStore: configuration, + settings: settings, + launchMode: .normal, + startup: MacPluginStartupResult(installedPlugins: packages, registeredPlugins: [], issues: []), + allowsLocalDevelopmentPackages: false + ) + } + func sign(_ package: URL) throws { let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: Data(contentsOf: package.appendingPathComponent("plugin.json"))) let document = try PluginPackageSignature.makeDocument( @@ -164,3 +314,12 @@ struct PluginBundledPublisherTrustTests { func remove() { try? FileManager.default.removeItem(at: root) } } } + +private final class MigrationPreferences: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} diff --git a/macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift b/macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift index 314e47fc29..5b94bf1317 100644 --- a/macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift +++ b/macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift @@ -19,6 +19,36 @@ struct PluginMarketplaceFeatureModelTests { isEnabled: true ) + @Test + func bundledJavaCanMoveToTheOfficialDownloadAtTheSameVersion() async throws { + let id = OfficialPluginCatalog.javaPluginID + let (feature, service, manager) = try makeModel(staleOfficialIDs: [id.rawValue]) + var object = try #require(JSONSerialization.jsonObject(with: + DeclarativePluginTestSupport.officialDescriptor("JavaSupport").normalizedManifest + ) as? [String: Any]) + object["version"] = "1.0.0" + let manifest = try DeclarativePluginTestSupport.descriptor(object).pluginManifest + func snapshot(_ status: PluginInstallationStatus) -> PluginManagementSnapshot { + PluginManagementSnapshot( + manifest: manifest, origin: .bundled, installationStatus: status, + isEnabled: false, isRequired: false, isRunning: false, isQuarantined: false, + isSuppressedBySafeMode: false, requiresRestart: false, canRollback: false, statusMessage: "Disabled", marketplace: nil + ) + } + manager.snapshots = [snapshot(.installed)] + let listing = try #require(feature.listings.first { $0.pluginID == id }) + let offer = try #require(feature.offer(for: listing)) + #expect(feature.installedSourceID(for: id) == PluginMarketplaceSource.officialID) + #expect(feature.installState(for: offer) == .updateAvailable(installed: "1.0.0", available: "1.0.0")) + #expect(feature.availableUpdate(for: id)?.entry.id == id.rawValue) + service.origin = .marketplace + await feature.install(offer) + #expect(manager.installed.count == 1) + #expect(feature.pendingConfirmation == nil) + manager.snapshots = [snapshot(.removed)] + #expect(feature.installState(for: offer) == .installable) + } + /// Rows and the detail pane ask for one URL at the same time; they share /// one request, and a rejected icon is not requested again per row. @Test diff --git a/macos/Tests/LitheTests/ProjectGenerationTests.swift b/macos/Tests/LitheTests/ProjectGenerationTests.swift index 9d4c1d3a95..e6a7a443e3 100644 --- a/macos/Tests/LitheTests/ProjectGenerationTests.swift +++ b/macos/Tests/LitheTests/ProjectGenerationTests.swift @@ -250,6 +250,18 @@ struct ProjectGenerationTests { // MARK: Window state + @Test + func missingJavaTemplatesOfferAnInstallationEntry() throws { + let missing = NewProjectFeatureModel(environment: try Self.environment(plugins: PluginStates(enabled: []), sources: [])) + missing.reloadCatalog() + #expect(missing.isJavaSupportMissing) + let installed = NewProjectFeatureModel(environment: try Self.environment( + plugins: PluginStates(enabled: []), sources: [try Self.source(Self.javaSupportDirectory, enabled: false)] + )) + installed.reloadCatalog() + #expect(!installed.isJavaSupportMissing) + } + @Test func offersToEnableDisabledProvidersAndFindsMissingOnes() async throws { let plugins = PluginStates(enabled: [Self.templatesPluginID]) diff --git a/scripts/build-official-plugins.sh b/scripts/build-official-plugins.sh index 31a8d40bed..76616c2b02 100755 --- a/scripts/build-official-plugins.sh +++ b/scripts/build-official-plugins.sh @@ -15,6 +15,7 @@ TRIPLE="" OUTPUT_DIR="" PLUGIN_ID="" BUNDLED_ONLY=false +MARKETPLACE_INDEX="$ROOT_DIR/Plugins/marketplace/index.json" # Architectures a package's own runtime is assembled for: arm64, x86_64, or # universal. Defaults to the architecture of --triple. RUNTIME_ARCH="" @@ -29,10 +30,11 @@ while [[ $# -gt 0 ]]; do --triple) TRIPLE="$2"; shift 2 ;; --output) OUTPUT_DIR="$2"; shift 2 ;; --bundled-only) BUNDLED_ONLY=true; shift ;; + --marketplace-index) MARKETPLACE_INDEX="$2"; shift 2 ;; --plugin-id) PLUGIN_ID="$2"; shift 2 ;; --arch) RUNTIME_ARCH="$2"; shift 2 ;; --development-unsigned) DEVELOPMENT_UNSIGNED=true; shift ;; - *) print -u2 -- "Usage: $0 --triple triple [--configuration debug|release] [--output directory] [--plugin-id id] [--arch arm64|x86_64|universal] [--bundled-only] [--development-unsigned]"; exit 2 ;; + *) print -u2 -- "Usage: $0 --triple triple [--configuration debug|release] [--output directory] [--plugin-id id] [--arch arm64|x86_64|universal] [--bundled-only] [--marketplace-index path] [--development-unsigned]"; exit 2 ;; esac done @@ -82,8 +84,9 @@ for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do if [[ -n "$PLUGIN_ID" && "$package_id" != "$PLUGIN_ID" ]]; then continue fi - if [[ "$BUNDLED_ONLY" == true ]] && ! node "$ROOT_DIR/scripts/official-plugin-distribution.mjs" "$package_id"; then - continue + if [[ "$BUNDLED_ONLY" == true ]]; then + bundled=$(node "$ROOT_DIR/scripts/official-plugin-distribution.mjs" "$package_id" "$MARKETPLACE_INDEX") + [[ "$bundled" == true ]] || continue fi schema_version=$(/usr/bin/plutil -extract schemaVersion raw "$manifest") if [[ "$schema_version" != "2" ]]; then diff --git a/scripts/official-plugin-distribution.mjs b/scripts/official-plugin-distribution.mjs index a1819d9859..66119b05db 100644 --- a/scripts/official-plugin-distribution.mjs +++ b/scripts/official-plugin-distribution.mjs @@ -1,15 +1,46 @@ #!/usr/bin/env node -// A package must opt into the base application; all other official plugins -// remain separately buildable and installable through plugin management. -// Go Support and PHP Support moved to community plugins in Lithe Store. Java -// Support ships inside Lithe.app so Java works offline on first launch; its -// manifest allows the sealed bundled copy while installed updates require a -// publisher signature. The user can remove and restore it in plugin management. +// Keep a transition copy until the checked-in official index offers a complete, +// compatible release. The decision is made at build time, never at app startup. +import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; -const bundledOfficialPluginIDs = new Set(["dev.lithe.plugin.java-support"]); -export function isBundledOfficialPlugin(id) { - return bundledOfficialPluginIDs.has(id); +import { loadConfig, releaseFor } from "./official-plugin-releases.mjs"; + +export const defaultIndexPath = new URL("../Plugins/marketplace/index.json", import.meta.url); +export function readDistributionIndex(path = defaultIndexPath) { + const index = JSON.parse(readFileSync(path, "utf8")); + if (index.schemaVersion !== 1 || !Array.isArray(index.plugins)) throw new Error("Invalid official plugin index"); + return index; +} +const canonical = (value) => JSON.stringify(Object.entries(value ?? {}).sort(([a], [b]) => a.localeCompare(b))); +const versionParts = (value) => /^\d+\.\d+\.\d+$/.test(value ?? "") ? value.split(".").map(Number) : null; + +export function isBundledOfficialPlugin(id, index = readDistributionIndex(), config = loadConfig()) { + const plugin = config.find((entry) => entry.id === id); + if (!plugin?.bundledUntilPublishedVersion) return false; + const minimum = versionParts(plugin.bundledUntilPublishedVersion); + if (!minimum) throw new Error("Invalid bundledUntilPublishedVersion"); + const current = releaseFor(plugin); + const published = index.plugins.find((entry) => entry.id === id); + if (published?.repository !== current.index.repository) return true; + return !published.versions?.some((version) => { + const parts = versionParts(version.version); + if (!parts) return false; + const comparison = parts[0] - minimum[0] || parts[1] - minimum[1] || parts[2] - minimum[2]; + if (comparison < 0 || version.pluginAPIVersion !== current.pluginAPIVersion + || canonical(version.requires) !== canonical(current.requires)) return false; + const assets = plugin.assets; + // Exactly one download per configured asset, with no ambiguous platform. + if (!Array.isArray(version.downloads) || version.downloads.length !== assets.length) return false; + return assets.every((asset) => version.downloads.filter((download) => + JSON.stringify(download.platforms?.slice().sort()) === JSON.stringify(asset.platforms.slice().sort()) + && /^[0-9a-f]{64}$/.test(download.sha256 ?? "") + && download.downloadURL === `${current.index.repository}/releases/download/${plugin.tagPrefix}${version.version}/${asset.assetName.replaceAll("{version}", version.version)}` + ).length === 1); + }); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - process.exitCode = isBundledOfficialPlugin(process.argv[2]) ? 0 : 1; + // Print a boolean so callers propagate malformed/missing input as a build + // failure instead of interpreting an exception's exit code as "omit". + const index = readDistributionIndex(process.argv[3]); + process.stdout.write(`${isBundledOfficialPlugin(process.argv[2], index)}\n`); } diff --git a/scripts/package-app.sh b/scripts/package-app.sh index 96a93eac57..8082bd057d 100755 --- a/scripts/package-app.sh +++ b/scripts/package-app.sh @@ -13,6 +13,7 @@ DIST_ROOT="${LITHE_DIST_ROOT:-$ROOT_DIR/dist}" SIGNING_IDENTITY="${LITHE_CODESIGN_IDENTITY:--}" ARM64_TRIPLE="arm64-apple-macosx" X86_64_TRIPLE="x86_64-apple-macosx" +MARKETPLACE_INDEX="${LITHE_OFFICIAL_PLUGIN_INDEX:-$ROOT_DIR/Plugins/marketplace/index.json}" case "$ARCH" in universal) APP_DIR="$DIST_ROOT/Lithe.app" ;; @@ -24,19 +25,22 @@ cd "$ROOT_DIR" # Java Support assembles JDT LS and its JDK into its own plugin package later # in this script. They are prepared here first so a missing or invalid # download fails before the application is compiled. -LITHE_ARCH="$ARCH" "$ROOT_DIR/scripts/prepare-jdtls.sh" > /dev/null -if [[ "$ARCH" == "universal" ]]; then - if [[ -n "${LITHE_JDK_ROOT:-}" ]]; then - print -u2 -- \ - "Universal packaging requires LITHE_JDK_ARM64_ROOT and LITHE_JDK_X86_64_ROOT instead of LITHE_JDK_ROOT" - exit 1 +bundle_java=$(node "$ROOT_DIR/scripts/official-plugin-distribution.mjs" dev.lithe.plugin.java-support "$MARKETPLACE_INDEX") +if [[ "$bundle_java" == true ]]; then + LITHE_ARCH="$ARCH" "$ROOT_DIR/scripts/prepare-jdtls.sh" > /dev/null + if [[ "$ARCH" == "universal" ]]; then + if [[ -n "${LITHE_JDK_ROOT:-}" ]]; then + print -u2 -- \ + "Universal packaging requires LITHE_JDK_ARM64_ROOT and LITHE_JDK_X86_64_ROOT instead of LITHE_JDK_ROOT" + exit 1 + fi + LITHE_JDK_ROOT="${LITHE_JDK_ARM64_ROOT:-}" LITHE_JDK_TARGET_ARCH="arm64" \ + "$ROOT_DIR/scripts/prepare-jdk.sh" > /dev/null + LITHE_JDK_ROOT="${LITHE_JDK_X86_64_ROOT:-}" LITHE_JDK_TARGET_ARCH="x86_64" \ + "$ROOT_DIR/scripts/prepare-jdk.sh" > /dev/null + else + LITHE_JDK_TARGET_ARCH="$ARCH" "$ROOT_DIR/scripts/prepare-jdk.sh" > /dev/null fi - LITHE_JDK_ROOT="${LITHE_JDK_ARM64_ROOT:-}" LITHE_JDK_TARGET_ARCH="arm64" \ - "$ROOT_DIR/scripts/prepare-jdk.sh" > /dev/null - LITHE_JDK_ROOT="${LITHE_JDK_X86_64_ROOT:-}" LITHE_JDK_TARGET_ARCH="x86_64" \ - "$ROOT_DIR/scripts/prepare-jdk.sh" > /dev/null -else - LITHE_JDK_TARGET_ARCH="$ARCH" "$ROOT_DIR/scripts/prepare-jdk.sh" > /dev/null fi if [[ "$ARCH" == "universal" ]]; then scripts/build-macos.sh --configuration release --triple "$ARM64_TRIPLE" @@ -94,6 +98,7 @@ else plugin_triple="$ARCH-apple-macosx" fi plugin_root=$(scripts/build-official-plugins.sh --bundled-only \ + --marketplace-index "$MARKETPLACE_INDEX" \ --configuration release \ --arch "$ARCH" \ --triple "$plugin_triple") @@ -109,7 +114,7 @@ cp -R "$remote_server_root" "$APP_DIR/Contents/Resources/RemoteServers" # The official marketplace index ships read-only so the catalog works # offline; refreshed copies are written to the user's Caches at runtime. mkdir -p "$APP_DIR/Contents/Resources/PluginMarketplace" -cp "$ROOT_DIR/Plugins/marketplace/index.json" "$APP_DIR/Contents/Resources/PluginMarketplace/index.json" +cp "$MARKETPLACE_INDEX" "$APP_DIR/Contents/Resources/PluginMarketplace/index.json" cp "$INFO_PLIST" "$APP_DIR/Contents/Info.plist" zsh "$ROOT_DIR/scripts/embed-sparkle.sh" "$APP_DIR" diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index a40329c0d9..12b76cb56d 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -390,7 +390,8 @@ async function main() { // revision/theme/viewport, never to a reusable build or runtime resource. // Includes plugin-ui-icons: signed runtime packages are never build-cache inputs. // official-plugin-packages also rejects dist/packages, dist/verify, per-architecture - // release ZIPs and java-plugin-distribution probes; origin trust is not transferable. + // release ZIPs, synthetic published-index.fixture.json and java-plugin-distribution probes; + // origin trust and build-index ownership are not transferable. // Includes agent-hook-library-state: user commands, native ownership journals and locks have no copy stage. // Includes agent-instruction-library-state: user text and sync locks have no build identity or copy stage. // Includes mcp-tool-probe-state: SDK sessions, native tool exclusions and diff --git a/scripts/test-official-plugin-distribution.mjs b/scripts/test-official-plugin-distribution.mjs index 9dbd7de5f8..d8224b4443 100644 --- a/scripts/test-official-plugin-distribution.mjs +++ b/scripts/test-official-plugin-distribution.mjs @@ -1,6 +1,75 @@ import assert from "node:assert/strict"; -import { isBundledOfficialPlugin } from "./official-plugin-distribution.mjs"; -assert.equal(isBundledOfficialPlugin("dev.lithe.plugin.go-support"), false); -assert.equal(isBundledOfficialPlugin("dev.lithe.plugin.php-support"), false); -assert.equal(isBundledOfficialPlugin("new.optional.plugin"), false); -console.log("Official plugin distribution checks passed"); +import test from "node:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { isBundledOfficialPlugin, readDistributionIndex } from "./official-plugin-distribution.mjs"; +import { loadConfig, releaseFor, updateIndex } from "./official-plugin-releases.mjs"; +const id = "dev.lithe.plugin.java-support"; +const config = loadConfig(); +const java = config.find((entry) => entry.id === id); +const release = releaseFor(java); +const empty = { schemaVersion: 1, plugins: [] }; +function published() { + return structuredClone(updateIndex(empty, release, { + checksums: Object.fromEntries(release.assets.map((asset, i) => [asset.assetName, String(i + 1).repeat(64)])), + releaseDate: "2026-10-25", + })); +} +test("unknown and retired plugins never enter the default app", () => { + for (const plugin of ["dev.lithe.plugin.go-support", "dev.lithe.plugin.php-support", "new.optional.plugin"]) { + assert.equal(isBundledOfficialPlugin(plugin), false); + } +}); +test("empty official source retains the transition Java package", () => { + assert.equal(isBundledOfficialPlugin(id, empty), true); + assert.equal(readDistributionIndex().schemaVersion, 1); +}); +test("both published architectures remove the default package even while automatic release is paused", () => { + assert.equal(java.releaseEnabled, false); + assert.equal(isBundledOfficialPlugin(id, published()), false); +}); +test("partial, ambiguous, invalid or foreign downloads keep the transition copy", () => { + const changes = [ + (entry) => entry.versions[0].downloads.pop(), + (entry) => { entry.versions[0].downloads[1] = entry.versions[0].downloads[0]; }, + (entry) => { entry.versions[0].downloads[0].sha256 = "invalid"; }, + (entry) => { entry.versions[0].downloads[0].downloadURL += "?wrong"; }, + (entry) => { entry.repository = "https://github.com/untrusted/plugin"; }, + (entry) => { entry.versions[0].requires.packageTrust = 2; }, + (entry) => { delete entry.versions[0].requires.packageTrust; }, + (entry) => { entry.versions[0].pluginAPIVersion = 1; }, + (entry) => { entry.versions[0].version = "1.0.0"; }, + ]; + for (const change of changes) { + const index = published(); change(index.plugins[0]); + assert.equal(isBundledOfficialPlugin(id, index), true); + } +}); +test("later compatible published versions also satisfy the transition cutoff", () => { + const index = published(); + const version = index.plugins[0].versions[0]; + version.version = "1.2.0"; + for (const download of version.downloads) download.downloadURL = download.downloadURL.replaceAll(release.version, version.version); + assert.equal(isBundledOfficialPlugin(id, index), false); +}); + +test("broken index input fails the CLI instead of returning an omit decision", { timeout: 15000 }, () => { + const directory = mkdtempSync(join(tmpdir(), "lithe-package-verification.")); + try { + const index = join(directory, "published-index.fixture.json"); + for (const contents of ["{broken", '{"schemaVersion":2,"plugins":[]}']) { + writeFileSync(index, contents); + const result = spawnSync(process.execPath, [ + fileURLToPath(new URL("./official-plugin-distribution.mjs", import.meta.url)), id, index, + ], { encoding: "utf8", timeout: 5000, maxBuffer: 1024 * 1024 }); + assert.equal(result.error, undefined); + assert.notEqual(result.status, 0); + assert.equal(result.stdout, ""); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index a08ac7e650..8b21c26724 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -245,6 +245,8 @@ try { assert.ok(packages.locations.includes("dist/packages///")); assert.ok(packages.locations.includes("dist/verify///")); assert.ok(packages.locations.includes(".artifacts/java-plugin-distribution/")); + assert.ok(packages.locations.includes("/lithe-package-verification.*/published-index.fixture.json")); + assert.match(packages.reason, /LITHE_OFFICIAL_PLUGIN_INDEX/); const refused = reuse(["--resource", packages.id]); assert.notEqual(refused.status, 0); assert.match(diagnostics(refused), /official-plugin-packages is isolated.*cannot be reused/); diff --git a/scripts/verify-macos-package.sh b/scripts/verify-macos-package.sh index 9576163936..d8645173ec 100755 --- a/scripts/verify-macos-package.sh +++ b/scripts/verify-macos-package.sh @@ -3,6 +3,12 @@ set -euo pipefail ROOT_DIR="${0:A:h:h}" cd "$ROOT_DIR" +SLIM=false +case "${1:-}" in + --slim) SLIM=true ;; + "") ;; + *) print -u2 -- "Usage: $0 [--slim]"; exit 2 ;; +esac java_debug_server_version="$( /usr/bin/plutil -extract javaDebugServerVersion raw -o - third_party/jdtls/manifest.json @@ -27,6 +33,20 @@ x86_64_jdk_root="$temporary_directory/jdk-x86_64" dist_root="$temporary_directory/dist" package_log="$temporary_directory/package.log" dmg_log="$temporary_directory/dmg.log" +marketplace_index="$ROOT_DIR/Plugins/marketplace/index.json" +if [[ "$SLIM" == true ]]; then + marketplace_index="$temporary_directory/published-index.fixture.json" + node --input-type=module - "$marketplace_index" <<'NODE' +import { writeFileSync } from "node:fs"; +import { loadConfig, releaseFor, updateIndex } from "./scripts/official-plugin-releases.mjs"; +const release = releaseFor(loadConfig().find((plugin) => plugin.id === "dev.lithe.plugin.java-support")); +writeFileSync(process.argv[2], JSON.stringify(updateIndex({ schemaVersion: 1, plugins: [] }, release, { + checksums: Object.fromEntries(release.assets.map((asset, i) => [asset.assetName, String(i + 1).repeat(64)])), + releaseDate: "2026-10-25", +}))); +NODE +fi +bundle_java=$(node scripts/official-plugin-distribution.mjs dev.lithe.plugin.java-support "$marketplace_index") # The package smoke test validates assembly rather than third-party downloads. # This fixture satisfies prepare-jdtls.sh's production layout checks while @@ -88,6 +108,7 @@ done : > "$jdtls_root/java-test/runner/$java_test_runner_name" : > "$jdtls_root/java-test/LICENSE-MIT.txt" +if [[ "$bundle_java" == true ]]; then for missing_configuration in config_mac_arm config_mac; do broken_jdtls_root="$temporary_directory/jdtls-missing-$missing_configuration" cp -R "$jdtls_root" "$broken_jdtls_root" @@ -106,6 +127,7 @@ for missing_configuration in config_mac_arm config_mac; do exit 1 fi done +fi cat > "$temporary_directory/java.c" <<'SOURCE' int main(void) { return 0; } @@ -119,7 +141,14 @@ JAVA_VERSION="21.0.0" RELEASE done +# Missing download inputs prove slim assembly never prepares Java resources. +if [[ "$bundle_java" == false ]]; then + jdtls_root="$temporary_directory/must-not-read-jdtls" + arm64_jdk_root="$temporary_directory/must-not-read-arm64-jdk" + x86_64_jdk_root="$temporary_directory/must-not-read-x64-jdk" +fi env -u LITHE_ARCH -u LITHE_JDK_ROOT \ + LITHE_OFFICIAL_PLUGIN_INDEX="$marketplace_index" \ LITHE_DIST_ROOT="$dist_root" \ LITHE_JDTLS_ROOT="$jdtls_root" \ LITHE_JDK_ARM64_ROOT="$arm64_jdk_root" \ @@ -207,6 +236,9 @@ required_resources=( "$app_path/Contents/Resources/MonacoEditor/ONIGURUMA-LICENSE.txt" "$app_path/Contents/Resources/MonacoEditor/VSCODE-LICENSE.txt" "$app_path/Contents/Resources/MonacoEditor/TEXTMATE-NOTICE.txt" +) +if [[ "$bundle_java" == true ]]; then +required_resources+=( "$app_path/$java_plugin_package/plugin.json" "$app_path/$java_plugin_package/$java_launcher_path" "$app_path/$java_plugin_package/language-server/configuration/arm64/config.ini" @@ -224,6 +256,16 @@ required_resources=( "$app_path/$java_plugin_package/icons/java.svg" "$app_path/$java_plugin_package/templates/java/maven/pom.xml" ) +else + if [[ -e "$app_path/$java_plugin_package" ]]; then + print -u2 -- "Slim app must not contain Java Support" + exit 1 + fi + if find "$app_path/Contents" -type f \( -name 'org.eclipse.jdt.ls.core_*.jar' -o -name 'libjvm.dylib' \) | /usr/bin/grep -q .; then + print -u2 -- "Slim app must not contain another JDT LS or JDK copy" + exit 1 + fi +fi for resource in "${required_resources[@]}"; do if [[ ! -e "$resource" ]]; then print -u2 -- "Packaged resource is missing: $resource" @@ -233,12 +275,14 @@ done /usr/bin/lipo "$app_path/Contents/MacOS/Lithe" -verify_arch arm64 x86_64 /usr/bin/lipo "$app_path/Contents/Frameworks/Sparkle.framework/Sparkle" -verify_arch arm64 x86_64 /usr/bin/otool -l "$app_path/Contents/MacOS/Lithe" | /usr/bin/grep -F '@executable_path/../Frameworks' >/dev/null +if [[ "$bundle_java" == true ]]; then /usr/bin/lipo \ "$app_path/$java_plugin_package/runtime/arm64/bin/java" \ -verify_arch arm64 /usr/bin/lipo \ "$app_path/$java_plugin_package/runtime/x86_64/bin/java" \ -verify_arch x86_64 +fi # JDT LS and its JDK belong to the Java Support plugin package. The # application itself must not carry a second copy outside that package. if [[ -e "$app_path/Contents/Resources/LanguageServers" ]]; then @@ -256,7 +300,7 @@ for optional_plugin in dev.lithe.plugin.php-support dev.lithe.plugin.go-support; fi done -if ! cmp -s "$app_path/Contents/Resources/PluginMarketplace/index.json" "$ROOT_DIR/Plugins/marketplace/index.json"; then +if ! cmp -s "$app_path/Contents/Resources/PluginMarketplace/index.json" "$marketplace_index"; then print -u2 -- "Packaged app must contain the official plugin marketplace index" exit 1 fi @@ -275,4 +319,4 @@ if [[ "$dmg_path" != "$expected_dmg_path" || ! -s "$dmg_path" ]]; then fi hdiutil imageinfo "$dmg_path" > /dev/null -print -- "macOS package and disk image verification passed for the default universal architecture" +print -- "macOS package and disk image verification passed for the universal architecture (Java bundled: $bundle_java)" diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index c386dc47e0..79c15a0619 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -129,10 +129,11 @@ "dist/packages///", "dist/verify///", "dist/Lithe-Java-Support--darwin-.zip", - ".artifacts/java-plugin-distribution/" + ".artifacts/java-plugin-distribution/", + "/lithe-package-verification.*/published-index.fixture.json" ], "identity": "Assembled plugin packages carry build-time downloads (JDT LS, its extensions and one JDK per target architecture for Java Support) and an optional publisher signature; no reliable worktree identity stamp. Plugin state directories are mutable language-server state of one installation. Independent release archives bind plugin version, darwin architecture, upstream input hashes, API/requires and final publisher signature; host-sealed bundled copies are a different origin, never a reusable package", - "reason": "Assemble each official declarative plugin independently in every worktree and validate it with LitheOfficialPluginVerifier; do not copy or symlink it across worktrees. Java Support is assembled from the reusable jdtls and jdk download caches by Plugins/mac/Official/JavaSupport/assemble-package.sh, so reuse those caches rather than the package. Never copy or share plugin state at any stage. Release verification extracts a separate copy and checks its publisher signature; delete per-run dist/verify and local integration output when no longer needed. Never transfer a sealed-origin exception to user-installed copies. Runtime installation versions stay read-only, preserving app signatures and Sparkle delta baselines." + "reason": "Assemble each official declarative plugin independently in every worktree and validate it with LitheOfficialPluginVerifier; do not copy or symlink it across worktrees. Java Support is assembled from the reusable jdtls and jdk download caches by Plugins/mac/Official/JavaSupport/assemble-package.sh, so reuse those caches rather than the package. Never copy or share plugin state at any stage. Release verification extracts a separate copy and checks its publisher signature; delete per-run dist/verify and local integration output when no longer needed. Never transfer a sealed-origin exception to user-installed copies. Runtime installation versions stay read-only, preserving app signatures and Sparkle delta baselines. Distribution decisions read the current checkout index or explicit LITHE_OFFICIAL_PLUGIN_INDEX/--marketplace-index build input; synthetic packaging indexes are per-test fixtures, never reusable or publishable source indexes." }, { "id": "language-tools", diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 069b094e6d..e19f40fa38 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -8882,7 +8882,9 @@ "native.turnedOnAutoMerge": "Turned on auto-merge", "native.visualization": "Visualization", "native.showVisualization": "Show visualization", - "native.hideVisualization": "Hide visualization" + "native.hideVisualization": "Hide visualization", + "plugins.java.installNotice": "Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept.", + "plugins.java.installTemplates": "Install Java Support for Java, Maven and Spring templates…" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 366178ec07..e0244b764a 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -8882,7 +8882,9 @@ "native.turnedOnAutoMerge": "已开启自动合并", "native.visualization": "可视化", "native.showVisualization": "显示可视化", - "native.hideVisualization": "隐藏可视化" + "native.hideVisualization": "隐藏可视化", + "plugins.java.installNotice": "Java Support 现已独立安装。请从插件市场安装,或使用“从磁盘安装”导入离线包。原有 Java 设置会保留。", + "plugins.java.installTemplates": "安装 Java Support,获取 Java、Maven 和 Spring 项目模板…" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 5f91ea6607..2daa909469 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -82971,6 +82971,28 @@ "key": "Hide visualization", "arguments": [] } + }, + { + "id": "plugins.java.installNotice", + "description": "macOS Java Support independent distribution installation entry", + "platforms": [ + "macos" + ], + "macos": { + "key": "Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept.", + "arguments": [] + } + }, + { + "id": "plugins.java.installTemplates", + "description": "macOS Java Support independent distribution installation entry", + "platforms": [ + "macos" + ], + "macos": { + "key": "Install Java Support for Java, Maven and Spring templates…", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/java-support-plugin.json b/shared/platform-feature-matrix/features/java-support-plugin.json index 149c2f3d97..1fd0e532fd 100644 --- a/shared/platform-feature-matrix/features/java-support-plugin.json +++ b/shared/platform-feature-matrix/features/java-support-plugin.json @@ -2,7 +2,7 @@ "id": "java-support-plugin", "area": "语言支持", "group": "Java", - "capability": "Java 语言服务器作为随应用发行的声明式插件:JDT LS、Lombok、调试与测试扩展及其 JDK 由插件包拥有,可卸载和恢复;运行、测试、调试仍由宿主内置实现提供,由插件在 jdt 集成的 capabilities 中声明;支持 packageTrust 1 的宿主按来源校验内置副本与发布者签名更新,安装时验证自带运行时和入口;双架构独立发布配置已准备但尚未启用上架", + "capability": "Java 语言服务器作为随应用发行的声明式插件:JDT LS、Lombok、调试与测试扩展及其 JDK 由插件包拥有,可卸载和恢复;运行、测试、调试仍由宿主内置实现提供,由插件在 jdt 集成的 capabilities 中声明;支持 packageTrust 1 的宿主按来源校验内置副本与发布者签名更新,安装时验证自带运行时和入口;双架构独立发布配置已准备但尚未启用上架;同 ID 安装保留默认启用和显式禁用,卸载不重新启用内置副本;缺包按需提示与模板安装入口;完整双架构索引上架后构建自动省去内置 JDT/JDK", "macos": { "evidence": [ "Plugins/mac/Official/JavaSupport/plugin.json", @@ -19,7 +19,10 @@ "macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift", "shared/contracts/plugins/v1/fixtures/publisher-bundled.plugin.json", "scripts/official-plugin-releases.mjs", - "Plugins/marketplace/official-plugins.json" + "Plugins/marketplace/official-plugins.json", + "macos/Sources/Lithe/Application/Features/JavaPluginTransition.swift", + "scripts/official-plugin-distribution.mjs", + "scripts/verify-macos-package.sh" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -33,5 +36,5 @@ "verificationStatus": "not-applicable" }, "owner": "Java Support", - "verification": "macOS:全新安装后打开插件管理,确认 Java Support 已安装且默认启用;打开 Maven 项目并点开 .java 文件,确认语言服务器启动,补全、诊断、跳转到 JDK 与依赖 jar 中的类(jdt://)、Lombok 生成的方法、运行标记可用;在打包出的应用里逐项确认宿主能力:工具栏调试能停在断点并继续,测试面板列出并能运行 Java 测试,“当前文件”与 Java main 运行配置能启动(Spring Boot 等 Maven 配置走另一条路径,不能替代这一项);用 lsof 或进程参数确认 JDT LS 及其 java 进程来自 Lithe.app/Contents/Resources/OfficialPlugins/dev.lithe.plugin.java-support,且应用内不存在 Contents/Resources/LanguageServers。卸载 Java Support 并重启:打开 .java 文件时提示需要安装或恢复该插件,语法高亮、Maven 面板和其他语言不受影响,/Lithe/PluginState 下该插件的目录被删除,app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。点击恢复并重启后 Java 功能恢复。从旧版本升级:首次打开 Java 项目会重建一次索引,之后正常。Windows:JDT LS 与 JDK 仍随应用安装目录分发,尚未接入该插件契约。 独立分发第一阶段:PluginBundledPublisherTrustTests 使用真实 Core 和注入测试公钥覆盖内置副本、已签名更新、用户目录伪造 bundled 来源、缺失运行时与篡改拒绝;官方发布索引回归验证双架构哈希与 tag 对应 API/requires。正式发布包、Intel 真机和完整产品下载迁移仍待验收,releaseEnabled 保持 false。" + "verification": "macOS:全新安装后打开插件管理,确认 Java Support 已安装且默认启用;打开 Maven 项目并点开 .java 文件,确认语言服务器启动,补全、诊断、跳转到 JDK 与依赖 jar 中的类(jdt://)、Lombok 生成的方法、运行标记可用;在打包出的应用里逐项确认宿主能力:工具栏调试能停在断点并继续,测试面板列出并能运行 Java 测试,“当前文件”与 Java main 运行配置能启动(Spring Boot 等 Maven 配置走另一条路径,不能替代这一项);用 lsof 或进程参数确认 JDT LS 及其 java 进程来自 Lithe.app/Contents/Resources/OfficialPlugins/dev.lithe.plugin.java-support,且应用内不存在 Contents/Resources/LanguageServers。卸载 Java Support 并重启:打开 .java 文件时提示需要安装或恢复该插件,语法高亮、Maven 面板和其他语言不受影响,/Lithe/PluginState 下该插件的目录被删除,app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。点击恢复并重启后 Java 功能恢复。从旧版本升级:首次打开 Java 项目会重建一次索引,之后正常。Windows:JDT LS 与 JDK 仍随应用安装目录分发,尚未接入该插件契约。 独立分发第一阶段:PluginBundledPublisherTrustTests 使用真实 Core 和注入测试公钥覆盖内置副本、已签名更新、用户目录伪造 bundled 来源、缺失运行时与篡改拒绝;官方发布索引回归验证双架构哈希与 tag 对应 API/requires。正式发布包、Intel 真机和完整产品下载迁移仍待验收,releaseEnabled 保持 false。 后续迁移回归覆盖默认启用/显式禁用、跳过过渡版的安装、旧移除标记不清理新状态、卸载更新不恢复内置副本;缺包提示不触发自动安装。verify-macos-package.sh 的默认与 --slim 模式分别验证空索引保留资源及完整 fixture 索引省去资源;fixture 不代表真实市场已经上线。" }