diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2162962..48850f4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,4 +1,11 @@
-# 契约 SDK 的日常验证。发布不走这里(见 release.yml),但**发布会做的事这里基本都做一遍**。
+# 契约 SDK 与插件构建支持包的日常验证。发布不走这里(见 release.yml),
+# 但**发布会做的事这里基本都做一遍**。
+#
+# 本仓库产出三个包,共用一个版本号:
+# VelaShell.PluginSdk 契约程序集
+# VelaShell.PluginSdk.Testing 测试替身
+# VelaShell.PluginSdk.Build 插件工程只需引用的那一个包
+# (2026-09-11 从 velashell-plugin-cli 搬来)
#
# 与 release.yml 的差别只有三处:不推 nuget.org、不上传产物到 Release、
# 以及 fork PR 拿不到强名称密钥时关掉签名继续跑。
@@ -91,9 +98,15 @@ jobs:
run: |
$version = '${{ steps.version.outputs.version }}'
New-Item -ItemType Directory -Force artifacts/nuget | Out-Null
+ # VelaShell.PluginSdk.Build 也在这一批里:它与契约包同仓库、同版本、同一次发布
+ # (2026-09-11 从 velashell-plugin-cli 搬过来)。顺序无所谓 —— 它对契约包和打包器
+ # 都是 ProjectReference,dotnet pack 会自己先把那两个工程建出来。
+ # (VelaShell.PluginSdk.Packer 不在这个列表里:它 IsPackable=false,只以构建产物的
+ # 形式躺在 .Build 包的 tools/ 下。)
$projects = @(
'src/VelaShell.PluginSdk/VelaShell.PluginSdk.csproj',
- 'src/VelaShell.PluginSdk.Testing/VelaShell.PluginSdk.Testing.csproj'
+ 'src/VelaShell.PluginSdk.Testing/VelaShell.PluginSdk.Testing.csproj',
+ 'src/VelaShell.PluginSdk.Build/VelaShell.PluginSdk.Build.csproj'
)
foreach ($project in $projects) {
dotnet pack $project -c Release -o artifacts/nuget -p:VelaSdkVersion=$version --nologo $env:VELA_SIGN_ARGS
@@ -101,11 +114,11 @@ jobs:
}
Get-ChildItem artifacts/nuget | Select-Object -ExpandProperty Name
- # ── 拆库之后新增的一步 ────────────────────────────────────────────────
- # 本仓库是 Avalonia 版本锁的**权威**,而这个事实只通过包里那一个 buildTransitive
- # props 文件传达给下游(宿主的 VerifyAvaloniaMatchesSdk、cli 仓库的
- # VerifyAvaloniaVersionPin 都读它)。单仓库时代那两处核对是直接读文件的,
- # 漏打进包也没人发现;现在包就是唯一通道,所以拆完包核对一遍它真的在里面。
+ # 本仓库是 Avalonia 版本锁的**权威**,而这个事实传给**宿主**的唯一通道就是包里那一个
+ # buildTransitive props 文件(宿主的 VerifyAvaloniaMatchesSdk 读它)。它没打进包也不会
+ # 让 pack 失败,所以拆开核对一遍。
+ # (.Build 现在同仓库,直接读 $(VelaAvaloniaVersion),不走这条通道 —— 见其
+ # VerifyAvaloniaVersionPin。所以这一步守的是宿主那一侧。)
- name: Verify the pin props shipped in the package
shell: pwsh
run: |
@@ -121,7 +134,7 @@ jobs:
Expand-Archive -Path $zip -DestinationPath (Join-Path $work 'x')
$props = Join-Path $work 'x/buildTransitive/VelaShell.PluginSdk.props'
if (-not (Test-Path $props)) {
- throw "buildTransitive/VelaShell.PluginSdk.props is missing from the package. Downstream repos read the pinned Avalonia version from it; without it the host and VelaShell.PluginSdk.Build lose their only cross-repo consistency check."
+ throw "buildTransitive/VelaShell.PluginSdk.props is missing from the package. The VelaShell host reads the pinned Avalonia version from it; without it the host loses its only consistency check against this SDK."
}
$text = Get-Content -Raw $props
$expected = [regex]::Match((Get-Content -Raw Directory.Build.props), '([^<]+)').Groups[1].Value
@@ -132,6 +145,24 @@ jobs:
Write-Host "Pinned Avalonia = $shipped, SDK package version = $version"
Write-Host $text
+ # 端到端冒烟:拿刚打出的包,站在插件作者的位置上走一遍
+ # (最小插件工程 → 还原 → 构建 → 出 .vpx → 读回容器 → 查共享程序集有没有漏)。
+ # 夹具在 tests/smoke/,细节见 scripts/Invoke-Smoke.ps1 的注释。
+ #
+ # 这一步不是形式,它挡两类东西:
+ # ① 插件工程是**仓库外环境**,仓库内的 Directory.Build.props 一条也吃不到 ——
+ # 历史上两个最难查的坑(CA2252 全线报错、AXAML 编译器到不了插件工程)
+ # 都只有在这个前提下才会显形;
+ # ② 打包器(VelaShell.PluginSdk.Packer)真的进了包、targets 真的调得动它 ——
+ # "随包分发、无需全局工具"是本包对插件作者的承诺,而漏打或调错了只有真跑一遍
+ # 才看得见(插件作者那边的现象是一句 VELA1003 或构建到一半 Exec 失败)。
+ # 改 targets 或改打包器命令面之后必跑。
+ - name: Smoke test (plugin project -> build -> .vpx)
+ shell: pwsh
+ run: |
+ & ./scripts/Invoke-Smoke.ps1 -Feed ./artifacts/nuget -Version '${{ steps.version.outputs.version }}'
+ if ($LASTEXITCODE) { exit $LASTEXITCODE }
+
- uses: actions/upload-artifact@v7
with:
name: nuget-packages
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9b33c13..2bbf380 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,15 +1,21 @@
-# 契约 SDK 的发布流水线:在 GitHub 页面**发布 Release** 时自动触发。
+# 插件契约 SDK 与构建支持包的发布流水线:在 GitHub 页面**发布 Release** 时自动触发。
#
-# 一次发布产出两个 NuGet 包,共用 Release 标签里的那个版本号:
+# 一次发布产出三个 NuGet 包,共用 Release 标签里的那个版本号:
#
# VelaShell.PluginSdk 契约程序集(插件与宿主唯一共享的类型)
# VelaShell.PluginSdk.Testing 测试替身(TestPluginContext 与各能力内存实现)
+# VelaShell.PluginSdk.Build 插件工程**只需引用的那一个包**(targets + 打包器 + 依赖锁)
#
-# **其余三个包不在这里发**(2026-08-27 拆库起):
-# VelaShell.Plugin.Cli / VelaShell.PluginSdk.Build … VelaShellLabs/velashell-plugin-cli
-# VelaShell.Plugin.Templates ………………………………… VelaShellLabs/velashell-plugin-templates
-# 那两个仓库有自己的版本号与自己的 Release 流水线。本仓库发 1.6.0 **不要求**它们跟着发;
-# 它们只在想吃到新契约时才把自己引用的 VelaShell.PluginSdk 版本抬上来。
+# .Build 于 2026-09-11 从 velashell-plugin-cli 搬来。它对契约包是 ProjectReference,
+# 因此传给插件工程的那份契约永远就是本仓库这一版 —— 原先"引用哪一版契约"那个跨仓库
+# 旋钮就此消失,连带它漂移的可能。插件工程出包用的打包器同样在本仓库
+# (src/VelaShell.PluginSdk.Packer,IsPackable=false,只随 .Build 的 tools/ 分发)。
+#
+# **另外两个包不在这里发**:
+# VelaShell.Plugin.Cli ……………… VelaShellLabs/velashell-plugin-cli
+# VelaShell.Plugin.Templates …… VelaShellLabs/velashell-plugin-templates
+# 那两个仓库有自己的版本号与自己的 Release 流水线,本仓库发版**不要求**它们跟着发,
+# 本仓库也**不引用**它们的任何东西 —— 依赖方向是单向的,没有环。
#
# 📌 版本号:**发版前在本地落好、随功能改动一起合进 main**。
# 跑一次 `pwsh scripts/Set-Version.ps1 <版本>`,它会把该版本写进 Directory.Build.props、
@@ -22,13 +28,16 @@
# Release 标签,与仓库里当时提交了什么无关。忘了第 ② 步的兜底是 CI 的版本同步体检。
#
# ⚠️ Avalonia 版本锁:本仓库是整个插件生态里这个版本号的**权威**。改 VelaAvaloniaVersion
-# 等于改插件生态的 Avalonia 版本 —— 必须与宿主同一波发布,并且发完之后让
-# velashell-plugin-cli 抬一次它引用的 SDK 版本,否则它那道 VerifyAvaloniaVersionPin 会红。
+# 等于改插件生态的 Avalonia 版本 —— 必须与宿主同一波发布,并且**同一个提交里**把
+# src/VelaShell.PluginSdk.Build 的两处副本(csproj 上那条精确区间、build/*.props 里的
+# 同名默认值)一起改掉,否则构建期的 VELA1000 / VELA1006 会红。
#
# 推送用 **NuGet Trusted Publishing(OIDC)**,不存 API Key:
# NuGet/login 拿本次运行的 GitHub OIDC 令牌去 nuget.org 换一把**短时效**的推送密钥,
# 用完即弃。密钥不落仓库机密,也就没有"泄漏了要轮换"这件事。
-# ⚠️ 首次发版前必须在 nuget.org 上为**这两个包**各建一条可信发布策略,三项都填新值:
+# ⚠️ 首次发版前必须在 nuget.org 上为**这三个包**各建一条可信发布策略,三项都填新值:
+# (VelaShell.PluginSdk.Build 原先那条指向 velashell-plugin-cli,搬库之后**必须重建**,
+# 否则推它的时候会被 nuget.org 拒掉 —— 而那时前两个包已经推上去了。)
# Repository owner VelaShellLabs
# Repository velashell-plugin-sdk
# Workflow file release.yml
@@ -126,9 +135,15 @@ jobs:
run: |
$version = '${{ steps.version.outputs.version }}'
New-Item -ItemType Directory -Force artifacts/nuget | Out-Null
+ # VelaShell.PluginSdk.Build 也在这一批里:它与契约包同仓库、同版本、同一次发布
+ # (2026-09-11 从 velashell-plugin-cli 搬过来)。顺序无所谓 —— 它对契约包和打包器
+ # 都是 ProjectReference,dotnet pack 会自己先把那两个工程建出来。
+ # (VelaShell.PluginSdk.Packer 不在这个列表里:它 IsPackable=false,只以构建产物的
+ # 形式躺在 .Build 包的 tools/ 下。)
$projects = @(
'src/VelaShell.PluginSdk/VelaShell.PluginSdk.csproj',
- 'src/VelaShell.PluginSdk.Testing/VelaShell.PluginSdk.Testing.csproj'
+ 'src/VelaShell.PluginSdk.Testing/VelaShell.PluginSdk.Testing.csproj',
+ 'src/VelaShell.PluginSdk.Build/VelaShell.PluginSdk.Build.csproj'
)
foreach ($project in $projects) {
dotnet pack $project -c Release -o artifacts/nuget -p:VelaSdkVersion=$version --nologo
@@ -136,9 +151,11 @@ jobs:
}
Get-ChildItem artifacts/nuget | Select-Object -ExpandProperty Name
- # 拆库之后,包里的 buildTransitive props 是 Avalonia 版本锁传给下游的**唯一通道**
- # (宿主的 VerifyAvaloniaMatchesSdk、cli 仓库的 VerifyAvaloniaVersionPin 都读它)。
- # 它没打进包也不会让 pack 失败,所以在推之前拆开核对一遍。
+ # 本仓库是 Avalonia 版本锁的**权威**,而这个事实传给**宿主**的唯一通道就是包里那一个
+ # buildTransitive props 文件(宿主的 VerifyAvaloniaMatchesSdk 读它)。它没打进包也不会
+ # 让 pack 失败,所以拆开核对一遍。
+ # (.Build 现在同仓库,直接读 $(VelaAvaloniaVersion),不走这条通道 —— 见其
+ # VerifyAvaloniaVersionPin。所以这一步守的是宿主那一侧。)
- name: Verify the pin props shipped in the package
shell: pwsh
run: |
@@ -154,7 +171,7 @@ jobs:
Expand-Archive -Path $zip -DestinationPath (Join-Path $work 'x')
$props = Join-Path $work 'x/buildTransitive/VelaShell.PluginSdk.props'
if (-not (Test-Path $props)) {
- throw "buildTransitive/VelaShell.PluginSdk.props is missing from the package. Downstream repos read the pinned Avalonia version from it; without it the host and VelaShell.PluginSdk.Build lose their only cross-repo consistency check."
+ throw "buildTransitive/VelaShell.PluginSdk.props is missing from the package. The VelaShell host reads the pinned Avalonia version from it; without it the host loses its only consistency check against this SDK."
}
$text = Get-Content -Raw $props
$expected = [regex]::Match((Get-Content -Raw Directory.Build.props), '([^<]+)').Groups[1].Value
@@ -165,6 +182,24 @@ jobs:
Write-Host "Pinned Avalonia = $shipped, SDK package version = $version"
Write-Host $text
+ # 端到端冒烟:拿刚打出的包,站在插件作者的位置上走一遍
+ # (最小插件工程 → 还原 → 构建 → 出 .vpx → 读回容器 → 查共享程序集有没有漏)。
+ # 夹具在 tests/smoke/,细节见 scripts/Invoke-Smoke.ps1 的注释。
+ #
+ # 这一步不是形式,它挡两类东西:
+ # ① 插件工程是**仓库外环境**,仓库内的 Directory.Build.props 一条也吃不到 ——
+ # 历史上两个最难查的坑(CA2252 全线报错、AXAML 编译器到不了插件工程)
+ # 都只有在这个前提下才会显形;
+ # ② 打包器(VelaShell.PluginSdk.Packer)真的进了包、targets 真的调得动它 ——
+ # "随包分发、无需全局工具"是本包对插件作者的承诺,而漏打或调错了只有真跑一遍
+ # 才看得见(插件作者那边的现象是一句 VELA1003 或构建到一半 Exec 失败)。
+ # 改 targets 或改打包器命令面之后必跑。
+ - name: Smoke test (plugin project -> build -> .vpx)
+ shell: pwsh
+ run: |
+ & ./scripts/Invoke-Smoke.ps1 -Feed ./artifacts/nuget -Version '${{ steps.version.outputs.version }}'
+ if ($LASTEXITCODE) { exit $LASTEXITCODE }
+
- uses: actions/upload-artifact@v7
with:
name: nuget-packages
diff --git a/AGENTS.md b/AGENTS.md
index 98180fd..24289f5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -37,20 +37,42 @@ VelaShell 生态的**全部文档**集中在一个仓库:
- **例外**:留在代码仓库里的少数几份文件不适用上述规则,因为它们服务的是「在这个仓库里写代码」
这件事,搬走只会离使用场景更远。各仓库的例外清单见下面第三节。
-## 三、本仓库:velashell-plugin-sdk(插件契约 SDK)
+## 三、本仓库:velashell-plugin-sdk(插件契约 SDK + 构建支持包)
-产出 `VelaShell.PluginSdk`(契约程序集)与 `VelaShell.PluginSdk.Testing`(测试替身)两个 NuGet 包。
-这是插件与宿主**唯一共享的那批类型**。
+产出三个**同版本、同一次发布**的 NuGet 包:
+
+| 包 | 是什么 |
+| --- | --- |
+| `VelaShell.PluginSdk` | 契约程序集 —— 插件与宿主**唯一共享的那批类型** |
+| `VelaShell.PluginSdk.Testing` | 测试替身 |
+| `VelaShell.PluginSdk.Build` | 插件工程**只需引用的那一个包**:targets + 随包分发的打包器 + 依赖锁 |
+
+外加一个**不发包**的工程:`src/VelaShell.PluginSdk.Packer`(`IsPackable=false`)——
+`.vpx` 打包器,只以构建产物的形式躺在 `.Build` 包的 `tools/` 下。
+
+`.Build` 于 2026-09-11 从 `velashell-plugin-cli` 搬来,打包器随后也做进了本仓库。
+两者都走 `ProjectReference`,所以发给插件作者的契约与打包器永远就是本仓库这一版 ——
+这正是搬过来要的效果:没有任何跨仓库的版本需要对齐。
### 构建与测试
```bash
dotnet build VelaShell.PluginSdk.slnx
dotnet test VelaShell.PluginSdk.slnx -c Debug
+
+# 端到端冒烟:拿刚打出的包当插件作者走一遍
+dotnet pack src/VelaShell.PluginSdk/VelaShell.PluginSdk.csproj -c Release -o artifacts/nuget
+dotnet pack src/VelaShell.PluginSdk.Build/VelaShell.PluginSdk.Build.csproj -c Release -o artifacts/nuget
+pwsh scripts/Invoke-Smoke.ps1 -Feed ./artifacts/nuget -Version <版本>
```
`-c Debug` 不是随口一说:Release 会打开强名称签名,而测试程序集不是签名友元,
-Release 下 `dotnet test` 编不过。签名密钥不入库,CI 从 `STRONG_NAME_KEY` 机密还原。
+Release 下 `dotnet test` 编不过。签名密钥不入库,CI 从 `STRONG_NAME_KEY` 机密还原;
+本地想打 Release 包又没有密钥时加 `-p:SignAssembly=false`。
+
+冒烟夹具在 `tests/smoke/`:一个手写的最小插件工程,刻意带两个空的
+`Directory.Build.props`/`.targets` 来切断向上查找 —— **插件工程是仓库外环境,仓库内的
+构建约定一条也吃不到**。**改了 `.Build` 的 targets、或抬了它引用的 vela-plugin 版本,必须跑它。**
### 只有本仓库能动的两件事
@@ -58,8 +80,24 @@ Release 下 `dotnet test` 编不过。签名密钥不入库,CI 从 `STRONG_NAME_
纪律是「SDK 主版本 == apiLevel」,由 `scripts/Set-Version.ps1` 在发版前硬核对。
**破坏性变更要先手工把 `Level` +1**:脚本会核对但不代改,因为「契约破没破」是人的判断。
2. **Avalonia 版本锁的权威** —— 值写在 `Directory.Build.props` 的 `VelaAvaloniaVersion`,
- 由本包经 `buildTransitive` 导出成 `$(VelaSdkPinnedAvaloniaVersion)`,宿主与 CLI 仓库在
- 各自构建期核对。改它 = 改整个插件生态的 Avalonia 版本,必须与宿主同一波发布。
+ 由 `VelaShell.PluginSdk` 包经 `buildTransitive` 导出成 `$(VelaSdkPinnedAvaloniaVersion)`
+ 供宿主核对;`.Build` 现在同仓库,直接读这个属性(`VerifyAvaloniaVersionPin`)。
+ 改它 = 改整个插件生态的 Avalonia 版本,必须与宿主同一波发布,并且在**同一个提交里**
+ 把 `.Build` 的两处副本(csproj 上那条精确区间、`build/*.props` 里的同名默认值)一起改掉。
+
+### 打包器为什么在本仓库、为什么是独立进程
+
+`.vpx` 的定义(`VpxContainer`)与清单规则(`PluginManifestReader`)本来就在
+`VelaShell.PluginSdk` 里 —— `vela-plugin` 只是它的另一个调用方。让 `.Build` 绕一趟 cli 仓库
+去取打包器,换来的只是一个要人盯着的跨仓库版本旋钮(2026-09-11 短暂存在过,已去掉)。
+
+**刻意不做成 MSBuild 任务**:VS 的 MSBuild 跑在 .NET Framework 上,而本仓库是 net11.0。
+做成 `` 的话,插件作者在 VS 里一按生成就会因为加载不了任务程序集而失败 ——
+而清单校验是 `AfterTargets="Build"` 的,每次生成都跑。`dotnet exec` 一个独立进程则与调用方
+的 MSBuild 是哪种运行时完全无关。
+
+改了打包器的命令面(`validate` / `pack` 的参数或输出),`.Build` 的 `build/*.targets`
+要跟着改,**并跑一次 `scripts/Invoke-Smoke.ps1`** —— 那个冒烟是这条链唯一的守门人。
### 版本号不归你定 —— 也不要为了自证能编译去造本地包
@@ -68,8 +106,10 @@ Release 下 `dotnet test` 编不过。签名密钥不入库,CI 从 `STRONG_NAME_
- **不要**跑 `scripts/Set-Version.ps1`、不要动 `Directory.Build.props` 的 `VelaSdkVersion`、
不要动 `VelaPluginApi.SdkVersion`。下一版是 1.6.0 还是 1.5.2、还是先发个 preview,
取决于当时排了什么、要不要跟宿主同波发 —— 这些你不知道。
-- **不要** `dotnet pack` 出本地包、不要在任何 `nuget.config` 里加本地源、
+- **不要** `dotnet pack` 出本地包给**别的仓库**用、不要在任何 `nuget.config` 里加本地源、
不要把下游仓库的 `Directory.Packages.props` 指到一个还不存在的版本。
+ (例外只有一个:`scripts/Invoke-Smoke.ps1` 会把包打进本仓库的临时源自己验自己,
+ 那是 CI 的常规步骤,产物不出本仓库、也不进任何人的 `nuget.config`。)
自己造一个包来让编译通过,等于把「这套东西还没发布」这个事实从构建结果里抹掉;
而且本地包必然**未签名**(`VelaShell.snk` 不在仓库里),下游一编译就是一片
`CS0012` 强名称不匹配 —— 那是自己制造的噪声,不是真问题。
diff --git a/Directory.Build.props b/Directory.Build.props
index dad8cd7..744a795 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,14 +14,23 @@
12.1.2
+
+
+
+
+
+
diff --git a/scripts/Invoke-Smoke.ps1 b/scripts/Invoke-Smoke.ps1
new file mode 100644
index 0000000..1996652
--- /dev/null
+++ b/scripts/Invoke-Smoke.ps1
@@ -0,0 +1,158 @@
+#Requires -Version 7.0
+<#
+.SYNOPSIS
+ 端到端冒烟:拿刚打出来的包,站在**插件作者**的位置上走一遍。
+
+.DESCRIPTION
+ 验的是 VelaShell.PluginSdk.Build:插件作者**只引这一个包**,它到不到位,这里说了算。
+ (本脚本随该包一起从 velashell-plugin-cli 搬到本仓库,2026-09-11。)
+
+ 拆库(2026-08-27)之前这一步是"装模板 → dotnet new → 构建 → 出 .vpx"。模板搬去
+ VelaShellLabs/velashell-plugin-templates 之后,不能再依赖那个包来验自己的包
+ —— 否则模板仓库出问题会让这里的 CI 无端变红,而且发包时还得先有模板包。
+
+ 所以夹具改成自带:tests/smoke/ 下是一个**手写的最小插件工程**,与 velaplugin-ui
+ 模板同形(命令 + 面板 + 编译期 AXAML)。本脚本把它复制到临时目录、把包版本填进去、
+ 从给定的本地源还原,然后:
+
+ 1. `dotnet build -t:PackVpx` —— 覆盖 targets 的全部四件事:共享程序集不落地、
+ Avalonia 版本核对、清单编译期校验、一步出包;
+ 2. 确认 bin/vpx/ 下真的有 .vpx;
+ 3. 用打包器的 info 把容器读回来(魔数、摘要、清单);
+ 4. 确认插件输出目录里**没有**共享程序集 —— 出现了就说明 exclude=Runtime 的链路断了。
+
+ 夹具刻意放在临时目录而不是原地构建:tests/smoke/ 里那两个空的
+ Directory.Build.props/.targets 已经切断了向上查找,但复制出去更贴近真实
+ (插件作者的工程不在本仓库里)。
+
+.PARAMETER Feed
+ 本地 NuGet 源目录,里面应有刚打出的 VelaShell.PluginSdk.Build.<版本>.nupkg
+ 与 VelaShell.PluginSdk.<版本>.nupkg(.Build 依赖同版本的契约包,而那一版还没上 nuget.org)。
+ 本仓库三个包之外不需要任何别的东西 —— 打包器就在 .Build 包的 tools/ 里。
+
+.PARAMETER Version
+ 要验的 VelaShell.PluginSdk.Build 版本号。
+
+.PARAMETER PackerDll
+ 用来做第 3 步读回校验的打包器。默认**从刚打出的 .Build 包里取**
+ (tools/net11.0/VelaShell.PluginSdk.Packer.dll)。
+
+ 刻意不用 src/VelaShell.PluginSdk.Packer 的 bin 产物:从包里取顺带核对了**打包器真的
+ 进了包**。漏了的话,插件作者 `-t:PackVpx` 时才会拿到 VELA1003,而那时已经发出去了。
+
+.PARAMETER WorkDirectory
+ 工作目录。默认取 RUNNER_TEMP(CI)或系统临时目录。
+
+.EXAMPLE
+ pwsh scripts/Invoke-Smoke.ps1 -Feed ./artifacts/nuget -Version 1.5.0
+#>
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)] [string] $Feed,
+ [Parameter(Mandatory)] [string] $Version,
+ [string] $PackerDll,
+ [string] $WorkDirectory
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+$root = Split-Path -Parent $PSScriptRoot
+$feedPath = (Resolve-Path $Feed).Path
+$fixture = Join-Path $root 'tests/smoke'
+if (-not (Test-Path $fixture)) { throw "冒烟夹具不存在:$fixture" }
+
+if (-not $WorkDirectory) {
+ $WorkDirectory = Join-Path ($env:RUNNER_TEMP ?? [IO.Path]::GetTempPath()) 'vela-plugin-smoke'
+}
+# 打包器解到**工作区之外**的兄弟目录:解进去的话,那些文件会成为插件工程的默认 None 项,
+# 平白给这个"模拟插件作者"的工程加一堆它本来不会有的东西。
+$packerDirectory = "$WorkDirectory.packer"
+foreach ($stale in @($WorkDirectory, $packerDirectory)) {
+ if (Test-Path $stale) { Remove-Item -Recurse -Force $stale }
+}
+New-Item -ItemType Directory -Force $WorkDirectory | Out-Null
+
+# ── 打包器从哪来 ────────────────────────────────────────────────────────────
+# 从**刚打出的那个包**里拆一份出来,而不是直接用 src/VelaShell.PluginSdk.Packer 的 bin ——
+# 顺带核对打包器真的进了包(它随包分发是本包对插件作者的承诺)。
+if (-not $PackerDll) {
+ $nupkg = Join-Path $feedPath "VelaShell.PluginSdk.Build.$Version.nupkg"
+ if (-not (Test-Path $nupkg)) { throw "本地源里没有 $nupkg;先 dotnet pack 出包再跑冒烟。" }
+ New-Item -ItemType Directory -Force $packerDirectory | Out-Null
+ # Expand-Archive 只认 .zip 扩展名,.nupkg 直接喂给它会被拒;先复制一份改名。
+ $zip = Join-Path $packerDirectory 'pkg.zip'
+ Copy-Item $nupkg $zip
+ Expand-Archive -Path $zip -DestinationPath (Join-Path $packerDirectory 'x')
+ $PackerDll = Join-Path $packerDirectory 'x/tools/net11.0/VelaShell.PluginSdk.Packer.dll'
+ if (-not (Test-Path $PackerDll)) {
+ throw @"
+VelaShell.PluginSdk.Build.$Version.nupkg 里没有 tools/net11.0/VelaShell.PluginSdk.Packer.dll。
+打包器随包分发是这个包对插件作者的承诺(`dotnet build -t:PackVpx` 不必装任何全局工具),
+漏了的话插件作者只会拿到一句 VELA1003。看 VelaShell.PluginSdk.Build.csproj 的
+AddVelaPackerToPackage。
+"@
+ }
+}
+if (-not (Test-Path $PackerDll)) { throw "找不到打包器:$PackerDll。" }
+
+Write-Host "== 冒烟:VelaShell.PluginSdk.Build $Version =="
+Write-Host " 源 $feedPath"
+Write-Host " 打包器 $PackerDll"
+Write-Host " 工作区 $WorkDirectory"
+
+# ── 铺夹具 ──────────────────────────────────────────────────────────────────
+Copy-Item -Recurse -Force (Join-Path $fixture '*') $WorkDirectory
+
+$csproj = Join-Path $WorkDirectory 'Smoke.csproj'
+$text = [IO.File]::ReadAllText($csproj)
+if ($text -notmatch 'VELA_BUILD_VERSION') {
+ throw "tests/smoke/Smoke.csproj 里找不到 VELA_BUILD_VERSION 占位符;夹具改过了?"
+}
+[IO.File]::WriteAllText($csproj, $text.Replace('VELA_BUILD_VERSION', $Version))
+
+# 很关键:不清掉的话机器上已有的 nuget.org 缓存或别的源可能把**上一版**
+# 同名包喂进来,冒烟就验不到刚打出的这一版了。nuget.org 仍要留着 —— Avalonia 从那来。
+@"
+
+
+
+
+
+
+
+
+"@ | Set-Content (Join-Path $WorkDirectory 'nuget.config')
+
+Push-Location $WorkDirectory
+try {
+ # ── 1. 构建 + 出包 ──────────────────────────────────────────────────────
+ dotnet build -c Release -t:PackVpx --nologo
+ if ($LASTEXITCODE -ne 0) { throw "插件工程构建失败(见上方输出)。" }
+
+ # ── 2. 产物在不在 ───────────────────────────────────────────────────────
+ $vpx = Get-ChildItem 'bin/vpx/*.vpx' -ErrorAction SilentlyContinue | Select-Object -First 1
+ if (-not $vpx) { throw "PackVpx 没产出 .vpx。" }
+ Write-Host " 产物 $($vpx.Name)"
+
+ # ── 3. 容器读得回来吗 ───────────────────────────────────────────────────
+ dotnet $PackerDll info $vpx.FullName
+ if ($LASTEXITCODE -ne 0) { throw "打包器 info 读不回刚打出的 .vpx。" }
+
+ # ── 4. 共享程序集有没有漏进插件输出目录 ─────────────────────────────────
+ # 漏了就说明 exclude=Runtime 的链路断了。这不是"包大了一点"的问题:
+ # 装载器一律让这些程序集回落到宿主那份,插件目录里那些副本只会误导人,
+ # 让人以为版本是自己带的那份说了算。
+ $leaked = Get-ChildItem 'bin/Release/net11.0' -Filter '*.dll' |
+ Where-Object { $_.Name -like 'Avalonia*' -or $_.Name -eq 'VelaShell.PluginSdk.dll' }
+ if ($leaked) {
+ throw "共享程序集漏进了插件输出目录:$($leaked.Name -join ', ')"
+ }
+
+ Write-Host "== 冒烟通过 =="
+}
+finally {
+ Pop-Location
+}
+
+exit 0
diff --git a/scripts/Set-Version.ps1 b/scripts/Set-Version.ps1
index fa143af..7fd1521 100644
--- a/scripts/Set-Version.ps1
+++ b/scripts/Set-Version.ps1
@@ -4,7 +4,9 @@
把 SDK 版本号写进本仓库里所有需要它的地方。
.DESCRIPTION
- 拆库之后本仓库只管**契约包**的版本号(VelaShell.PluginSdk / .Testing),落点缩到四处:
+ 本仓库这一个版本号管三个同时发布的包(VelaShell.PluginSdk / .Testing / .Build ——
+ .Build 于 2026-09-11 从 velashell-plugin-cli 搬来,见 Directory.Build.props 的注释)。
+ 落点四处:
Directory.Build.props —— 包版本的默认值
src/…/VelaPluginApi.cs SdkVersion 常量 —— 宿主写进 host.json 的值,
@@ -18,12 +20,17 @@
docs 那两处不影响功能,但它们是给人照抄的 —— 2026-08-30 全部文档搬到
VelaShellLabs/velashell-docs 之后,它们不在本仓库的 checkout 里,所以找不到就跳过。
- **不在本仓库的落点**(2026-08-27 拆库起,各自由所在仓库的同名脚本管):
+ **不在本仓库的落点**(各自由所在仓库的同名脚本管):
· dotnet new 模板的 sdkVersion 默认值,以及 velashell-docs 里
zh|en/templates/dev-guide.md 的 PackageReference 片段 …… velashell-plugin-templates
· velashell-docs 里 zh|en/cli/cli.md 的版本横幅 ……… velashell-plugin-cli
- 那几处跟的是 VelaShell.PluginSdk.Build / VelaShell.Plugin.Cli 的版本,与本仓库无关 ——
- 这正是拆库要的效果:SDK 发 1.6.0 不必惊动模板和 CLI。
+ 前者跟的是 VelaShell.PluginSdk.Build 的版本 —— 那**现在就是本仓库的版本号**,
+ 所以本仓库发完版之后,想让 `dotnet new velaplugin` 生成的工程指过来,要去模板仓库
+ 抬一次 VelaBuildPackageVersion 再发一版模板(不做也不会坏:新建的工程继续引用上一版
+ .Build 包,那是完全可用的)。后者跟的是 vela-plugin 的版本,与本仓库无关。
+
+ 打包器(src/VelaShell.PluginSdk.Packer)没有自己的版本号:它 IsPackable=false,
+ 只以构建产物的形式随 .Build 的 tools/ 分发,跟着本仓库这一个版本号走。
发版流水线在解析出 Release 标签之后**第一件事**也会跑本脚本
(见 .github/workflows/release.yml),因此产物永远与标签一致,与仓库里当时提交了什么
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 8c3460c..9549ba5 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -34,7 +34,11 @@
+ STRONG_NAME_KEY 机密还原。本地 Debug 构建不签名,不需要密钥。
+ ⚠️ VelaShell.PluginSdk.Build 是例外:它没有对外的程序集面(IncludeBuildOutput=false),
+ 在自己的 csproj 里把 SignAssembly 与 IncludeSymbols 都显式关掉了 —— 不关的话,
+ 本地 `dotnet pack -c Release` 会因为找不到 .snk 在 CSC 那一步失败,
+ 而 IncludeSymbols 会生成一个空 .snupkg 让 nuget.org 直接 400。理由见那边的注释。 -->
true
$(VelaStrongNameKey)
diff --git a/src/VelaShell.PluginSdk.Build/README.md b/src/VelaShell.PluginSdk.Build/README.md
new file mode 100644
index 0000000..4b956c0
--- /dev/null
+++ b/src/VelaShell.PluginSdk.Build/README.md
@@ -0,0 +1,39 @@
+# VelaShell.PluginSdk.Build
+
+VelaShell 插件工程唯一需要引用的包。引一个,下面这些全都到位:
+
+```xml
+
+```
+
+| 它做了什么 | 为什么 |
+| --- | --- |
+| 传递引入 `VelaShell.PluginSdk`(契约)与 **与宿主版本一致的 Avalonia**,均为编译期引用 | 装载器强制插件与宿主共享这两类程序集;版本不一致会在用户机器上才炸 |
+| `EnableDynamicLoading=true`、`plugin.json` 进输出目录 | 插件是被动态装载的组件,ALC 靠 `deps.json` 解析自带依赖 |
+| 共享程序集的运行时资产不落插件目录 | 放进去也不会被加载,只是撑大包体 |
+| Avalonia 版本冲突从警告升为错误(NU1608/NU1605) | 版本漂移必须在构建期就红,而不是装机后才现形 |
+| 构建后按宿主同一套规则校验 `plugin.json` | 杜绝"本机构建过、宿主装不上" |
+| `dotnet build -t:PackVpx` 一步出 `.vpx` | 打包器随包分发,不必安装任何全局工具 |
+
+## 出包
+
+```bash
+dotnet build -c Release -t:PackVpx
+# → bin/vpx/<插件id>-<版本>.vpx
+
+# 带签名(密钥用 `vela-plugin keygen` 生成,不要提交进仓库)
+dotnet build -c Release -t:PackVpx -p:VelaSigningKey=/path/to/key.pem
+```
+
+## 可调属性
+
+| 属性 | 默认 | 说明 |
+| --- | --- | --- |
+| `VelaPluginManifest` | `$(MSBuildProjectDirectory)\plugin.json` | 清单路径 |
+| `VelaVpxOutputDirectory` | `bin\vpx\` | `.vpx` 产物目录 |
+| `VelaSigningKey` | 空 | 打包时用的 PEM 私钥 |
+| `VelaPackMask` | `true` | 是否对载荷做掩码变换 |
+| `VelaValidateManifestOnBuild` | `true` | 构建后是否校验清单 |
+| `VelaSkipAvaloniaVersionCheck` | `false` | 跳过 Avalonia 版本一致性检查 |
+
+完整开发指南:
diff --git a/src/VelaShell.PluginSdk.Build/VelaShell.PluginSdk.Build.csproj b/src/VelaShell.PluginSdk.Build/VelaShell.PluginSdk.Build.csproj
new file mode 100644
index 0000000..dd58602
--- /dev/null
+++ b/src/VelaShell.PluginSdk.Build/VelaShell.PluginSdk.Build.csproj
@@ -0,0 +1,126 @@
+
+
+
+ VelaShell.PluginSdk.Build
+ VelaShell 插件工程的构建支持包:一次引用即得到契约程序集、与宿主版本一致的 Avalonia、共享程序集不落地的处理、清单编译期校验,以及 `dotnet build -t:PackVpx` 一步出 .vpx 包。插件工程只需引用本包。
+ velashell;plugin;msbuild;vpx;sdk
+
+
+ false
+
+ false
+
+ false
+ false
+ false
+
+ $(NoWarn);NU5128
+ $(TargetsForTfmSpecificContentInPackage);AddVelaPackerToPackage
+
+
+
+
+
+
+
+
+
+
+ compile; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_VelaAvaloniaPin>@(PackageReference->WithMetadataValue('Identity','Avalonia')->'%(Version)')
+ <_VelaAvaloniaPin>$(_VelaAvaloniaPin.Replace('[','').Replace(']',''))
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_VelaPackerOutputDir>$(MSBuildThisFileDirectory)..\VelaShell.PluginSdk.Packer\bin\$(Configuration)\$(TargetFramework)\
+
+
+ <_VelaPackerFile Include="$(_VelaPackerOutputDir)*.dll;$(_VelaPackerOutputDir)*.json" />
+
+
+
+
+
+
diff --git a/src/VelaShell.PluginSdk.Build/build/VelaShell.PluginSdk.Build.props b/src/VelaShell.PluginSdk.Build/build/VelaShell.PluginSdk.Build.props
new file mode 100644
index 0000000..4ac47ec
--- /dev/null
+++ b/src/VelaShell.PluginSdk.Build/build/VelaShell.PluginSdk.Build.props
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+ true
+
+
+ true
+
+
+ $(MSBuildProjectDirectory)\plugin.json
+
+
+ 12.1.2
+
+
+ $(WarningsAsErrors);NU1608;NU1605
+
+
+ true
+
+
+ true
+
+
+
+
+
+
+
+
diff --git a/src/VelaShell.PluginSdk.Build/build/VelaShell.PluginSdk.Build.targets b/src/VelaShell.PluginSdk.Build/build/VelaShell.PluginSdk.Build.targets
new file mode 100644
index 0000000..fda9e00
--- /dev/null
+++ b/src/VelaShell.PluginSdk.Build/build/VelaShell.PluginSdk.Build.targets
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+ $(VelaPluginCli)
+ $(MSBuildThisFileDirectory)..\tools\net11.0\VelaShell.PluginSdk.Packer.dll
+ $(MSBuildProjectDirectory)\bin\vpx\
+
+
+
+
+
+
+
+ runtime
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_VelaAvaloniaRef Include="@(ReferencePath)" Condition="'%(ReferencePath.NuGetPackageId)' == 'Avalonia'" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_VelaPackArgs>pack "$(TargetDir.TrimEnd('\'))" --output "$(VelaVpxOutputDirectory.TrimEnd('\'))"
+ <_VelaPackArgs Condition="'$(VelaPackMask)' != 'true'">$(_VelaPackArgs) --no-mask
+ <_VelaPackArgs Condition="'$(VelaSigningKey)' != ''">$(_VelaPackArgs) --key "$(VelaSigningKey)"
+
+
+
+
+
diff --git a/src/VelaShell.PluginSdk.Packer/Program.cs b/src/VelaShell.PluginSdk.Packer/Program.cs
new file mode 100644
index 0000000..fa2f6e2
--- /dev/null
+++ b/src/VelaShell.PluginSdk.Packer/Program.cs
@@ -0,0 +1,265 @@
+using System.IO.Compression;
+using System.Security.Cryptography;
+using VelaShell.PluginSdk.Manifest;
+using VelaShell.PluginSdk.Packaging;
+
+namespace VelaShell.PluginSdk.Packer;
+
+///
+/// .vpx 打包器。VelaShell.PluginSdk.Build 把本工程的构建产物收进包的 tools/,
+/// 它的 targets 用 dotnet exec 调这里的 validate 与 pack。
+///
+///
+/// 命令面**刻意只有三条**,而且只服务于一个调用方(那套 targets)——
+/// 面向人的完整工具是 vela-plugin(VelaShellLabs/velashell-plugin-cli),
+/// 它有商店、开发内环、签名与体检。这里多加一条命令,就多一份要与那边对齐的东西。
+///
+/// 真正的逻辑一行都不在这里:容器格式在 ,清单规则在
+/// ,两者都在 VelaShell.PluginSdk —— 也就是**同一个包**
+/// 发给插件工程编译用的那份契约。"打包器与契约同版本"因此是构建出来的事实,不是约定。
+///
+internal static class Program
+{
+ private static int Main(string[] args)
+ {
+ try
+ {
+ if (args.Length == 0)
+ {
+ return Usage();
+ }
+ string[] rest = args[1..];
+ return args[0] switch
+ {
+ "validate" => Validate(rest),
+ "pack" => Pack(rest),
+ "info" => Info(rest),
+ "help" or "--help" or "-h" => Usage(),
+ _ => throw new PackerException($"Unknown command '{args[0]}'. Try validate | pack | info.")
+ };
+ }
+ catch (PackerException ex)
+ {
+ Console.Error.WriteLine($"error: {ex.Message}");
+ return 1;
+ }
+ // 清单/容器两类异常带的是"哪一条规则没过",原样透出比包一层更有用。
+ catch (Exception ex) when (ex is PluginManifestException or VpxFormatException
+ or IOException or UnauthorizedAccessException)
+ {
+ Console.Error.WriteLine($"error: {ex.Message}");
+ return 1;
+ }
+ }
+
+ private static int Usage()
+ {
+ Console.WriteLine("""
+ vela .vpx packer - invoked by the VelaShell.PluginSdk.Build MSBuild targets.
+
+ validate check plugin.json and the entry assembly
+ pack [options] pack a plugin output directory into .vpx
+ -o, --output destination (default: next to )
+ --no-mask do not mask the payload
+ -k, --key sign with this ECDSA P-256 private key
+ info print the container header and manifest
+
+ For everyday use install the full tool: dotnet tool install -g VelaShell.Plugin.Cli
+ """);
+ return 0;
+ }
+
+ /// 校验清单与入口程序集(目录或 plugin.json 路径均可)。
+ private static int Validate(string[] args)
+ {
+ Options options = Options.Parse(args);
+ string target = Path.GetFullPath(options.Positional.FirstOrDefault() ?? ".");
+ string directory = Directory.Exists(target) ? target : Path.GetDirectoryName(target)!;
+ PluginManifest manifest = LoadManifest(directory);
+ RequireEntry(directory, manifest);
+
+ Console.WriteLine($"OK {manifest.Id} v{manifest.Version} ({manifest.DisplayName})");
+ Console.WriteLine($" entry {manifest.Entry}");
+ Console.WriteLine($" hostMode {manifest.HostMode}");
+ Console.WriteLine($" apiLevel {manifest.ApiLevel} (this SDK: {VelaPluginApi.Level})");
+ Console.WriteLine($" author {manifest.Author ?? manifest.Publisher ?? "(not set)"}");
+ if (manifest.ApiLevel > VelaPluginApi.Level)
+ {
+ Warn($"apiLevel {manifest.ApiLevel} is newer than this SDK ({VelaPluginApi.Level}); "
+ + "hosts built on this SDK will refuse to load the plugin.");
+ }
+ if (manifest.Author is null && manifest.Publisher is null)
+ {
+ Warn("neither \"author\" nor \"publisher\" is set - the plugin manager page will show no author.");
+ }
+ return 0;
+ }
+
+ /// 把插件产物目录打成 .vpx。
+ private static int Pack(string[] args)
+ {
+ Options options = Options.Parse(args);
+ string source = Path.GetFullPath(options.Positional.FirstOrDefault() ?? ".");
+ PluginManifest manifest = LoadManifest(source);
+ RequireEntry(source, manifest);
+
+ string fileName = $"{manifest.Id}-{manifest.Version}{VpxContainer.FileExtension}";
+ string output = options.Get("--output") is { } requested
+ // 目录 → 用约定文件名;否则当成完整路径。MSBuild 的 PackVpx 传的就是目录。
+ ? Path.GetFullPath(Directory.Exists(requested)
+ || !requested.EndsWith(VpxContainer.FileExtension, StringComparison.OrdinalIgnoreCase)
+ ? Path.Combine(requested, fileName)
+ : requested)
+ : Path.GetFullPath(Path.Combine(source, "..", fileName));
+
+ using ECDsa? key = LoadPrivateKey(options.Get("--key"));
+ VpxContainer.Pack(source, output, new()
+ {
+ Mask = !options.Has("--no-mask"),
+ SigningKey = key
+ });
+
+ VpxPackageInfo info = VpxContainer.ReadInfo(output);
+ Console.WriteLine($"Packed {manifest.Id} v{manifest.Version}");
+ Console.WriteLine($" -> {output}");
+ Console.WriteLine($" payload {info.PayloadLength} bytes, sha256 {info.PayloadSha256}");
+ Console.WriteLine($" {(info.Signature is null ? "unsigned" : "signed by " + VpxContainer.PublicKeyFingerprint(info.Signature.PublicKey))}");
+ return 0;
+ }
+
+ ///
+ /// 打印容器头、签名状态与包里的清单。targets 用不到它 —— 它在这里是给 CI 的端到端冒烟
+ /// 做"把刚打出的包读回来"那一步用的(见 scripts/Invoke-Smoke.ps1),顺带也方便排障。
+ ///
+ private static int Info(string[] args)
+ {
+ Options options = Options.Parse(args);
+ string package = Path.GetFullPath(options.Positional.FirstOrDefault()
+ ?? throw new PackerException("Missing package path. Usage: info "));
+ if (!File.Exists(package))
+ {
+ throw new PackerException($"Package not found: {package}");
+ }
+
+ VpxPackageInfo info = VpxContainer.ReadInfo(package);
+ Console.WriteLine(Path.GetFileName(package));
+ Console.WriteLine($" format v{info.FormatVersion}");
+ Console.WriteLine($" flags {info.Flags}");
+ Console.WriteLine($" payload {info.PayloadLength} bytes");
+ Console.WriteLine($" sha256 {info.PayloadSha256}");
+ VpxSignatureState signature = VpxContainer.VerifySignature(info);
+ Console.WriteLine($" signature {(signature == VpxSignatureState.Trusted ? "Valid" : signature.ToString())}"
+ + (info.Signature is { } block ? $" ({VpxContainer.PublicKeyFingerprint(block.PublicKey)})" : ""));
+
+ // 光有摘要看不出这是哪个插件,把清单从载荷里读出来。
+ using Stream payload = VpxContainer.OpenPayload(package);
+ using ZipArchive archive = new(payload, ZipArchiveMode.Read);
+ if (archive.GetEntry(PluginManifestReader.FileName) is { } entry)
+ {
+ using StreamReader reader = new(entry.Open());
+ PluginManifest manifest = PluginManifestReader.Parse(reader.ReadToEnd());
+ Console.WriteLine($" plugin {manifest.Id} v{manifest.Version} ({manifest.DisplayName})");
+ Console.WriteLine($" author {manifest.Author ?? manifest.Publisher ?? "(not set)"}");
+ }
+ return 0;
+ }
+
+ // ---- helpers ----------------------------------------------------------
+
+ private static PluginManifest LoadManifest(string directory)
+ {
+ string manifestPath = Path.Combine(directory, PluginManifestReader.FileName);
+ if (!File.Exists(manifestPath))
+ {
+ throw new PackerException($"No {PluginManifestReader.FileName} in '{directory}'. "
+ + "Point the command at the plugin's build output directory.");
+ }
+ return PluginManifestReader.Load(manifestPath);
+ }
+
+ private static void RequireEntry(string directory, PluginManifest manifest)
+ {
+ if (!File.Exists(Path.Combine(directory, manifest.Entry)))
+ {
+ throw new PackerException($"Entry assembly '{manifest.Entry}' is missing from '{directory}'. "
+ + "Build the plugin project first.");
+ }
+ }
+
+ private static ECDsa? LoadPrivateKey(string? path)
+ {
+ if (path is null)
+ {
+ return null;
+ }
+ string full = Path.GetFullPath(path);
+ if (!File.Exists(full))
+ {
+ throw new PackerException($"Key file not found: {full}");
+ }
+ ECDsa key = ECDsa.Create();
+ try
+ {
+ key.ImportFromPem(File.ReadAllText(full));
+ }
+ catch (ArgumentException ex)
+ {
+ key.Dispose();
+ throw new PackerException($"'{full}' is not a PEM private key: {ex.Message}");
+ }
+ return key;
+ }
+
+ private static void Warn(string message) => Console.WriteLine($"warning: {message}");
+}
+
+/// 参数已经用尽了可诊断的信息,剩下的只是把话说给人听。
+internal sealed class PackerException(string message) : Exception(message);
+
+///
+/// 极简参数解析。调用方只有那套 targets,形状是固定的:一个位置参数 + 三个选项。
+/// 不引第三方命令行库 —— 本工程要被原样复制进别人的 tools/,依赖越少越好。
+///
+internal sealed class Options
+{
+ private readonly Dictionary _options = [with(StringComparer.Ordinal)];
+
+ public List Positional { get; } = [];
+
+ public static Options Parse(string[] args)
+ {
+ Options parsed = new();
+ for (int i = 0; i < args.Length; i++)
+ {
+ string token = args[i];
+ if (token.Length == 0 || token[0] != '-')
+ {
+ parsed.Positional.Add(token);
+ continue;
+ }
+ string? name = token switch
+ {
+ "--output" or "-o" => "--output",
+ "--key" or "-k" => "--key",
+ _ => null
+ };
+ if (name is null)
+ {
+ // 开关(目前只有 --no-mask)。不认识的也收下:多一个未知开关不该让构建失败,
+ // 而漏掉一个带值选项会让下一个 token 被当成位置参数,那才是会出错的方向。
+ parsed._options[token] = null;
+ continue;
+ }
+ if (i + 1 >= args.Length)
+ {
+ throw new PackerException($"'{token}' needs a value.");
+ }
+ parsed._options[name] = args[++i];
+ }
+ return parsed;
+ }
+
+ public bool Has(string name) => _options.ContainsKey(name);
+
+ public string? Get(string name) => _options.TryGetValue(name, out string? value) ? value : null;
+}
diff --git a/src/VelaShell.PluginSdk.Packer/README.md b/src/VelaShell.PluginSdk.Packer/README.md
new file mode 100644
index 0000000..3cf315c
--- /dev/null
+++ b/src/VelaShell.PluginSdk.Packer/README.md
@@ -0,0 +1,29 @@
+# VelaShell.PluginSdk.Packer
+
+`.vpx` 打包器。**不发 NuGet 包** —— 它以构建产物的形式被
+[`VelaShell.PluginSdk.Build`](../VelaShell.PluginSdk.Build/README.md) 收进那个包的
+`tools/net11.0/`,插件工程 `dotnet build -t:PackVpx` 调的就是它。
+
+```
+validate 校验 plugin.json 与入口程序集
+pack [-o ] [--no-mask] [-k ]
+info 打印容器头、签名状态与清单
+```
+
+## 三个设计决定
+
+**为什么是独立进程,不是 MSBuild 任务。** VS 的 MSBuild 跑在 .NET Framework 上,而本仓库是
+net11.0。做成 `` 的话,插件作者在 VS 里一按生成就会因为加载不了任务程序集而失败
+—— 而清单校验是 `AfterTargets="Build"` 的,每次生成都跑。`dotnet exec` 一个独立进程与调用方
+的 MSBuild 是哪种运行时完全无关。
+
+**为什么在本仓库,不在 velashell-plugin-cli。** 打包器需要的东西——`VpxContainer`(容器格式)
+与 `PluginManifestReader`(清单规则)——本来就在 `VelaShell.PluginSdk` 里,它们才是 `.vpx` 的
+定义。放在这里,`.Build` 发出去的打包器与它发出去的契约天然同版本,两个仓库之间不需要任何
+版本对齐。
+
+**为什么命令面只有三条。** 面向人的完整工具是
+[`vela-plugin`](https://github.com/VelaShellLabs/velashell-plugin-cli)(商店、开发内环、
+签名、体检)。这里只留 targets 真正会调的那两条,外加冒烟用来把包读回来的 `info`。
+多一条命令就多一份要与那边对齐的东西 —— 而两边一致性的根据是它们走同一个 `VpxContainer`,
+不是命令面长得像。
diff --git a/src/VelaShell.PluginSdk.Packer/VelaShell.PluginSdk.Packer.csproj b/src/VelaShell.PluginSdk.Packer/VelaShell.PluginSdk.Packer.csproj
new file mode 100644
index 0000000..84c078e
--- /dev/null
+++ b/src/VelaShell.PluginSdk.Packer/VelaShell.PluginSdk.Packer.csproj
@@ -0,0 +1,38 @@
+
+
+
+ Exe
+ VelaShell.PluginSdk.Packer
+ VelaShell.PluginSdk.Packer
+
+
+ false
+ false
+ true
+
+
+ false
+ false
+
+
+
+
+
+
+
diff --git a/tests/smoke/DemoPanel.axaml b/tests/smoke/DemoPanel.axaml
new file mode 100644
index 0000000..9f1ac00
--- /dev/null
+++ b/tests/smoke/DemoPanel.axaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/smoke/DemoPanel.axaml.cs b/tests/smoke/DemoPanel.axaml.cs
new file mode 100644
index 0000000..8d068a1
--- /dev/null
+++ b/tests/smoke/DemoPanel.axaml.cs
@@ -0,0 +1,36 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using VelaShell.PluginSdk;
+
+namespace Smoke;
+
+/// 面板视图。同时验证 Avalonia 与契约 SDK 在插件工程里都编译期可见。
+public sealed partial class DemoPanel : UserControl
+{
+ private readonly IPluginContext _context;
+
+ /// 由 ShowPanelAsync 的工厂在 UI 线程构造。
+ public DemoPanel(IPluginContext context)
+ {
+ _context = context;
+ InitializeComponent();
+ CountSessionsButton.Click += OnCountSessionsAsync;
+ }
+
+ private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
+
+ private async void OnCountSessionsAsync(object? sender, RoutedEventArgs e)
+ {
+ try
+ {
+ var sessions = await _context.Sessions.ListAsync(_context.Shutdown).ConfigureAwait(true);
+ StatusText.Text = $"{sessions.Count} session(s).";
+ }
+ catch (Exception ex)
+ {
+ _context.Log.Error("Counting sessions failed.", ex);
+ StatusText.Text = "Failed - see the plugin log.";
+ }
+ }
+}
diff --git a/tests/smoke/Directory.Build.props b/tests/smoke/Directory.Build.props
new file mode 100644
index 0000000..86793aa
--- /dev/null
+++ b/tests/smoke/Directory.Build.props
@@ -0,0 +1,14 @@
+
+
diff --git a/tests/smoke/Directory.Build.targets b/tests/smoke/Directory.Build.targets
new file mode 100644
index 0000000..50cfaf8
--- /dev/null
+++ b/tests/smoke/Directory.Build.targets
@@ -0,0 +1,2 @@
+
+
diff --git a/tests/smoke/PluginMain.cs b/tests/smoke/PluginMain.cs
new file mode 100644
index 0000000..6c15ba2
--- /dev/null
+++ b/tests/smoke/PluginMain.cs
@@ -0,0 +1,43 @@
+using VelaShell.PluginSdk;
+using VelaShell.PluginSdk.Ui;
+
+namespace Smoke;
+
+///
+/// 冒烟夹具的插件入口。刻意与 velaplugin-ui 模板同形 —— 它要覆盖的是同一批链路:
+/// 契约程序集可见、命令注册、面板工厂、以及编译期 AXAML。
+///
+[VelaPlugin]
+public sealed class SmokePlugin : IVelaPlugin
+{
+ private IPluginContext? _context;
+
+ ///
+ public Task ActivateAsync(IPluginContext context, CancellationToken cancellationToken)
+ {
+ _context = context;
+ context.Log.Info("Smoke activated.");
+ context.Commands.Register(new(
+ $"{context.PluginId}.open-panel",
+ "Smoke: Open panel",
+ "Smoke",
+ OpenPanelAsync));
+ return Task.CompletedTask;
+ }
+
+ private async Task OpenPanelAsync(CancellationToken cancellationToken)
+ {
+ IPluginContext context = _context!;
+ await context.Ui.ShowPanelAsync(
+ new() { Title = "Smoke", DisplayMode = PanelDisplayMode.Document },
+ () => new DemoPanel(context),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public Task DeactivateAsync(CancellationToken cancellationToken)
+ {
+ _context = null;
+ return Task.CompletedTask;
+ }
+}
diff --git a/tests/smoke/Smoke.csproj b/tests/smoke/Smoke.csproj
new file mode 100644
index 0000000..b52baea
--- /dev/null
+++ b/tests/smoke/Smoke.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net11.0
+ enable
+ enable
+ true
+
+ $(NoWarn);AVLN3001
+
+
+
+
+
+
+
+
diff --git a/tests/smoke/plugin.json b/tests/smoke/plugin.json
new file mode 100644
index 0000000..3e7f749
--- /dev/null
+++ b/tests/smoke/plugin.json
@@ -0,0 +1,21 @@
+{
+ "id": "velashell.smoke",
+ "version": "0.1.0",
+ "displayName": "Smoke",
+ "description": "CI smoke fixture: exercises the VelaShell.PluginSdk.Build package end to end.",
+ "author": "CI",
+ "publisher": "velashell",
+ "entry": "Smoke.dll",
+ "apiLevel": 1,
+ "hostMode": "inProcess",
+ "activationEvents": [ "onCommand:velashell.smoke.open-panel" ],
+ "contributes": {
+ "commands": [
+ {
+ "id": "velashell.smoke.open-panel",
+ "title": "Smoke: Open panel",
+ "category": "Smoke"
+ }
+ ]
+ }
+}