Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,17 @@ jobs:
# 共享程序集绝不能落进插件目录 —— 落了就说明 SDK 包的 exclude=Runtime 链路断了。
# 这条在插件侧再验一遍(工具链仓库的 CI 只在模板生成的空工程上验过),
# 因为真实插件才有第三方依赖,才可能把 Avalonia 从别的包里拖回来。
#
# MicroCom.Runtime 单列一条:它是 Avalonia 的依赖,但**不以 Avalonia 打头** ——
# 既躲过装载器的共享前缀判定(插件会加载自己那一份),也躲过上面那条按名字前缀的体检。
# 直接引 Avalonia.* 包的插件(DockerPanel 引了 Avalonia.AvaloniaEdit)一旦漏写
# ExcludeAssets="runtime",它就会静悄悄落进插件目录 —— 2026-09-11 并 DockerPanel 时
# 实测过这条路:构建 0 错 0 警告,包也照常打出来,只有肉眼逐个文件看才发现。
- name: Assert no shared assemblies leaked
shell: pwsh
run: |
$leaked = Get-ChildItem artifacts/plugins-bundle -Recurse -Filter '*.dll' |
Where-Object { $_.Name -like 'Avalonia*' -or $_.Name -eq 'VelaShell.PluginSdk.dll' }
Where-Object { $_.Name -like 'Avalonia*' -or $_.Name -eq 'VelaShell.PluginSdk.dll' -or $_.Name -eq 'MicroCom.Runtime.dll' }
if ($leaked) {
throw "Shared assemblies leaked into the plugin bundle: $(($leaked | ForEach-Object FullName) -join ', ')"
}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ jobs:
shell: pwsh
run: |
$leaked = Get-ChildItem artifacts/plugins-bundle -Recurse -Filter '*.dll' |
Where-Object { $_.Name -like 'Avalonia*' -or $_.Name -eq 'VelaShell.PluginSdk.dll' }
Where-Object { $_.Name -like 'Avalonia*' -or $_.Name -eq 'VelaShell.PluginSdk.dll' -or $_.Name -eq 'MicroCom.Runtime.dll' }
if ($leaked) {
throw "Shared assemblies leaked into the plugin bundle: $(($leaked | ForEach-Object FullName) -join ', ')"
}
Expand Down
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ VelaShell 生态的**全部文档**集中在一个仓库:

## 三、本仓库:velashell-plugins(第一方插件)

Redis / S3 / Telnet / 串口等第一方插件,以 Release 资产 `velashell-plugins-<版本>.zip` 交付。
Redis / S3 / Telnet / 串口 / Docker 面板等第一方插件,以 Release 资产 `.vpx`(每插件一份,已签名)交付。

### 构建与打包

Expand All @@ -65,6 +65,13 @@ S3 见 [`zh/host/S3协议插件化设计.md`](https://github.com/VelaShellLabs/v
Telnet / 串口见 [`zh/host/Telnet与串口可行性调研.md`](https://github.com/VelaShellLabs/velashell-docs/blob/main/zh/host/Telnet与串口可行性调研.md)。
**改了这些插件的设计取舍,要回写对应那篇。**

Docker 面板是个例外:它从独立仓库 `VelaShell.Plugin.DockerPanel` 并进来(2026-09-11),
设计取证留在插件目录自己的 [`README.md`](plugins/VelaShell.Plugin.DockerPanel/README.md) 与
[`plan.md`](plugins/VelaShell.Plugin.DockerPanel/plan.md)(后者是对着
`VelaShell.Plugin.DockerPanel.pen` 的 19 个画板逐条核对的完成度快照)。
这三份是上面第二节说的那类**例外**:它们服务的是"在这个目录里改这个插件",
跟着代码走比搬进 velashell-docs 更有用。

### 视觉对齐宿主

插件面板的几何与配色取自宿主的
Expand Down
22 changes: 22 additions & 0 deletions Directory.Build.targets
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,26 @@
Code="VELAP1000"
Text="Avalonia 版本与 SDK 锁的不一致:VelaShell.PluginSdk $(VelaSdkPackageVersion) 锁的是 $(VelaSdkPinnedAvaloniaVersion),而本仓库 Directory.Packages.props 里的 Avalonia 是 $(_VelaAvaloniaPin)。把 Directory.Packages.props 里三个 Avalonia* 都改成 $(VelaSdkPinnedAvaloniaVersion)。" />
</Target>

<!--
AvaloniaEdit 版本与 SDK 锁的一致性核对(随 Docker 面板插件从独立仓库并入)。

它走自己的版本线,由 SDK 单独导出一个权威值 $(VelaSdkPinnedAvaloniaEditVersion)。
程序集名 AvaloniaEdit 同样命中宿主 PluginAssemblyLoadContext 的 SharedPrefixes("Avalonia"),
运行时一律回落到宿主那份 —— 于是版本漂了**在本仓库一个测试都不会红**:
要等用户装上插件、打开一个 compose.yaml 才炸,而那时离根因已经非常远。
上面那条 VELAP1000 核的是测试工程的 Avalonia,拦不住这一路。

没有插件引 AvaloniaEdit 时这一条为空、跳过 —— 那是合法状态(不同于 Avalonia:
测试工程必须有它)。所以这里不像 VELAP1000 那样对"读不到"也判红。
-->
<Target Name="VerifyAvaloniaEditMatchesSdk" BeforeTargets="BeforeBuild"
Condition="'$(VelaSdkPinnedAvaloniaEditVersion)' != ''">
<PropertyGroup>
<_VelaAvaloniaEditPin>@(PackageVersion->WithMetadataValue('Identity','Avalonia.AvaloniaEdit')->'%(Version)')</_VelaAvaloniaEditPin>
</PropertyGroup>
<Error Condition="'$(_VelaAvaloniaEditPin)' != '' and '$(VelaSdkPinnedAvaloniaEditVersion)' != '$(_VelaAvaloniaEditPin)'"
Code="VELAP1001"
Text="AvaloniaEdit 版本与 SDK 锁的不一致:VelaShell.PluginSdk $(VelaSdkPackageVersion) 锁的是 $(VelaSdkPinnedAvaloniaEditVersion),而本仓库 Directory.Packages.props 里的 Avalonia.AvaloniaEdit 是 $(_VelaAvaloniaEditPin)。程序集名 AvaloniaEdit 命中装载器的 &quot;Avalonia&quot; 前缀,运行时一律回落到宿主那份 —— 版本漂了要等用户打开 compose.yaml 才炸。把它改成 $(VelaSdkPinnedAvaloniaEditVersion)。" />
</Target>
</Project>
14 changes: 11 additions & 3 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
<!-- 插件工程只需要这一个包:契约程序集、与宿主版本一致的 Avalonia(含 AXAML 编译器)、
plugin.json 处理、清单编译期校验与 `dotnet build -t:PackVpx` 都随它到位。
所以下面**没有** Avalonia 的条目给插件用 —— 插件不该自己声明 Avalonia。 -->
<PackageVersion Include="VelaShell.PluginSdk.Build" Version="2.0.2" />
<PackageVersion Include="VelaShell.PluginSdk.Build" Version="2.0.4" />
<!-- 测试替身:TestPluginContext 与各能力的内存实现,不起宿主即可测插件逻辑。 -->
<PackageVersion Include="VelaShell.PluginSdk.Testing" Version="2.0.2" />
<PackageVersion Include="VelaShell.PluginSdk.Testing" Version="2.0.4" />
</ItemGroup>
<ItemGroup Label="Avalonia(仅测试工程用:测试宿主扮演装载方,要提供运行时那份)">
<!-- ⚠️ 必须与 VelaShell.PluginSdk.Build 锁给插件工程的那一版**完全相同** ——
Expand All @@ -31,9 +31,17 @@
<!-- 菜单等控件的模板由主题提供:没有它 MenuItem 无模板、不可命中,真实点击测不了。 -->
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.2" />
</ItemGroup>
<ItemGroup Label="Docker 面板插件">
<!-- MIT。**只在编译期** —— 程序集名 AvaloniaEdit 命中装载器的 "Avalonia" 前缀,
运行时一律回落到宿主那份,因此版本必须与宿主一致。权威值同样来自 SDK 包
(VelaSdkPinnedAvaloniaEditVersion),由 Directory.Build.targets 的
VerifyAvaloniaEditMatchesSdk 在构建期核对(VELAP1001)。
漂了**在本仓库一个测试都不会红**:要等用户装上插件、打开一个 compose.yaml 才炸。 -->
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
</ItemGroup>
<ItemGroup Label="Redis 插件">
<!-- MIT,与本仓库 AGPL-3.0 + 商业双许可相容。随插件目录分发。 -->
<PackageVersion Include="StackExchange.Redis" Version="3.1.31" />
<PackageVersion Include="StackExchange.Redis" Version="3.2.0" />
</ItemGroup>
<ItemGroup Label="S3 插件">
<!-- Apache-2.0,与本仓库 AGPL-3.0 + 商业双许可相容。随插件目录分发。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
> 当前版本 **2.0.0** · SDK **2.0.0**

[VelaShell](https://github.com/joesdu/VelaShell) 官方维护的插件,一个解决方案管起来:
Redis、S3、Telnet、串口,外加示例插件 HelloWorld
Redis、S3、Telnet、串口、Docker 面板

三个仓库各管一摊,别串:

Expand Down
2 changes: 2 additions & 0 deletions VelaShell.Plugins.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<File Path="plugins/Directory.Build.props" />
<File Path="plugins/Directory.Build.targets" />
<File Path="plugins/README.md" />
<Project Path="plugins/VelaShell.Plugin.DockerPanel/VelaShell.Plugin.DockerPanel.csproj" />
<Project Path="plugins/VelaShell.Plugin.Redis/VelaShell.Plugin.Redis.csproj" />
<Project Path="plugins/VelaShell.Plugin.S3/VelaShell.Plugin.S3.csproj" />
<Project Path="plugins/VelaShell.Plugin.Serial/VelaShell.Plugin.Serial.csproj" />
Expand All @@ -29,6 +30,7 @@
<File Path="tests/Directory.Build.props" />
<File Path="tests/Directory.Build.targets" />
<File Path="tests/velashell.runsettings" />
<Project Path="tests/VelaShell.Plugin.DockerPanel.Tests/VelaShell.Plugin.DockerPanel.Tests.csproj" />
<Project Path="tests/VelaShell.Plugin.Redis.Tests/VelaShell.Plugin.Redis.Tests.csproj" />
<Project Path="tests/VelaShell.Plugin.S3.Tests/VelaShell.Plugin.S3.Tests.csproj" />
<Project Path="tests/VelaShell.Plugin.Serial.Tests/VelaShell.Plugin.Serial.Tests.csproj" />
Expand Down
54 changes: 40 additions & 14 deletions plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ SDK 契约与开发文档在工具链仓库

| 目录 | id | 随包分发 | 装载模式 | 说明 |
| --- | --- | --- | --- | --- |
| [VelaShell.Plugin.HelloWorld](VelaShell.Plugin.HelloWorld/) | `velashell.hello-world` | | 隔离进程 | 官方示例:SDK 各能力的最小用法 |
| [VelaShell.Plugin.DockerPanel](VelaShell.Plugin.DockerPanel/) | `velashell.dockerpanel` | | 进程内 | 远端 Docker 管理面板:容器 / 镜像 / 卷 / 网络 / Compose,含实时统计、日志、容器内文件编辑与内置终端 |
| [VelaShell.Plugin.Redis](VelaShell.Plugin.Redis/) | `velashell.redis` | 是 | 进程内 | Redis 客户端:键浏览、类型化查看与编辑、命令执行 |
| [VelaShell.Plugin.S3](VelaShell.Plugin.S3/) | `velashell.s3` | 是 | 进程内 | S3 兼容对象存储:协议 + 桶管理器 + 对象检视器(协议能力域的首个使用者) |
| [VelaShell.Plugin.Serial](VelaShell.Plugin.Serial/) | `velashell.serial` | 是 | 进程内 | RS-232 / USB 转串口终端:端口热插拔枚举、换行归一化、发送节流、Break 与 DTR/RTS |
Expand All @@ -19,7 +19,20 @@ SDK 契约与开发文档在工具链仓库
隔离插件跑在独立的 `VelaShell.PluginHost` 进程里(实现在主仓库),崩溃不波及宿主;
S3 与 Redis 因为**协议能力只在进程内可用**必须进程内装载 —— 协议是宿主反向调用插件的
高频通道,隔离进程的 RPC 只承载插件→宿主方向(清单校验会直接拒绝 protocols + isolated 的组合);
Telnet 与串口同理。
Telnet 与串口同理。Docker 面板的理由是另一条:它要把一个原生 Avalonia 控件挂进主窗口标签区
(控件无法跨进程嵌入),而且 `IRemoteTunnelApi` 交给它的是一条**活的 `Stream`**,
隔离进程里明确不可用。

> **Docker 面板要 SDK ≥ 2.0.0**(`plugin.json` 里钉了 `minSdkVersion`)。它说的是
> Docker Engine 的 HTTP API,而这条 API 的载体是远端的一个 unix socket ——
> 要的是到那个 socket 的**裸字节双工流**(`IRemoteTunnelApi`)。SDK 1.1 的远程执行只有
> "整段 UTF-8 解码"与"按 `\n` 切行"两种**文本**形态,承载不了分块传输、tar 归档流与
> `exec` 的多路复用帧:UTF-8 解码把非法字节换成 U+FFFD(不可逆),按行切分在 `0x0A`
> 处把一帧劈成两半 —— 那不是慢一点,是**数据静默损坏**。`apiLevel` 表达不了这一档
> (它只在**破坏性**变更时才动),所以另钉 `minSdkVersion`。
>
> 它也是本仓库**唯一直接引 Avalonia.\* 包**的插件(`Avalonia.AvaloniaEdit`,用于
> compose.yaml / .env 的语法高亮)。这一条有个反直觉的后果,见下一节最后那段。

> **串口插件要 SDK ≥ 1.5.0**。它是连接表单三件新面的驱动者与首个使用者:
> `ProtocolFeatures.NoEndpoint`(收起端口栏)、`ProtocolSettingKind.DynamicChoice` +
Expand Down Expand Up @@ -56,16 +69,36 @@ Telnet 与串口同理。
`VelaExcludeSharedRuntimeAssets` 已经按装载器的判定口径(`VelaShell.PluginSdk`
与 `Avalonia*` 前缀)把共享程序集的运行时资产排掉了。

### 例外:确实需要某个 `Avalonia.*` 包时,`ExcludeAssets="runtime"` 必须自己写

上一段那条"不要写 `ExcludeAssets`"的前提是**插件不直接引 Avalonia 包** ——
前四个插件都不引,SDK 包处理它自己那条引用就够了。

DockerPanel 要 `Avalonia.AvaloniaEdit` 做语法高亮,于是撞上了另一面:
`VelaExcludeSharedRuntimeAssets` 排得掉 `Avalonia.AvaloniaEdit` 自己的运行时资产,
**排不掉它带进来的传递依赖**。去掉那条 `ExcludeAssets`,
`AvaloniaEdit → Avalonia → MicroCom.Runtime` 里的 `MicroCom.Runtime.dll`
就会落进插件目录(2026-09-11 并库时实测,构建 0 错 0 警告,包照常打出来)。

它不以 `Avalonia` 打头,因此**两道防线同时漏掉它**:装载器的共享前缀判定不认它
(插件会加载自己那一份,与宿主的 Avalonia COM 互操作分属两个类型标识),
CI 那条泄漏体检原本也只认 `Avalonia*` 与 `VelaShell.PluginSdk.dll`。
体检已经把 `MicroCom.Runtime.dll` 补进去了,但**规矩仍然是显式写**:

```xml
<PackageReference Include="Avalonia.AvaloniaEdit" ExcludeAssets="runtime" />
```

本目录的 `Directory.Build.props/targets` 只额外做三件仓库自己的事:
`VelaPluginShip`(是否随应用分发)、构建后镜像到 `artifacts/plugins/<目录名>/`
与本机宿主、以及发布期的 `GetVelaPluginPayload`。

## 分发

"随包分发"由 csproj 的 `<VelaPluginShip>` 控制(默认 `true`)。示例插件设 `false`:
"随包分发"由 csproj 的 `<VelaPluginShip>` 控制(默认 `true`)。设成 `false` 的插件
本机构建仍会镜像到 `artifacts/plugins/`(以及 `VELASHELL_DEV_APP_DIR` 指定的应用目录),
装载起来验证插件系统没问题,但它不会被收进分发布局 ——
它是给开发者读的范例,不是给用户装的功能。
装载起来验证插件系统没问题,但它不会被收进分发布局 —— 给开发者读的范例用这一档。
(当前五个插件都是 `true`;示例插件 HelloWorld 已于 2026-08 移除。)

[`build/PluginBundle.proj`](../build/PluginBundle.proj) 的 `Bundle` 目标把 `VelaPluginShip=true`
的插件收成安装包 `plugins/` 那一层的布局:它不再作为 Release 资产上传,只在 CI 与发布流水线里
Expand All @@ -86,17 +119,10 @@ Telnet 与串口同理。
与 MSBuild 的 `VelaPluginsVersion` 毫无关系。两处必须一起写,只写一处就会出现
"发了 1.4.0,包却叫 velashell.redis-0.1.0.vpx"。

## 规划中(尚未创建)

- **串口插件**(`velashell.serial`):与 Telnet 同为终端协议能力的使用者;
依赖 `System.IO.Ports`,要处理三平台端口枚举与 `Close()` 死锁。
连接对话框里的「串口」页签在它落地前保持禁用占位。
- **容器管理插件**:基于远程执行能力封装 docker/podman 常用操作
(已有独立仓库原型 `joesdu/VelaShell.Plugin.DockerPanel`,尚未并入本仓库)。

## 新建插件

1. 复制 `VelaShell.Plugin.HelloWorld/` 为新目录,改 csproj 中的 `<VelaPluginId>` 与 `plugin.json`;
1. 挑一个形态最接近的现有插件复制成新目录(终端类看 Telnet,面板类看 DockerPanel),
改 csproj 中的 `<VelaPluginId>` 与 `plugin.json`;
2. 新依赖的版本加进根 `Directory.Packages.props`(中央包管理,csproj 里不写 `Version=`);
3. 把项目与它的测试工程加入 `VelaShell.Plugins.slnx` 的 `/plugins/`、`/tests/` 文件夹;
4. `dotnet build plugins/VelaShell.Plugin.<名字>` —— 输出自动镜像到
Expand Down
94 changes: 94 additions & 0 deletions plugins/VelaShell.Plugin.DockerPanel/Docker/BatchRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
namespace VelaShell.Plugin.DockerPanel.Docker;

/// <summary>批量操作里单个目标的结果。</summary>
/// <param name="Target">目标的显示名(容器名 / 卷名)。</param>
/// <param name="Succeeded">是否成功。</param>
/// <param name="Failure">失败原因(daemon 的原话);成功时为 <see langword="null" />。</param>
public readonly record struct BatchOutcome(string Target, bool Succeeded, string? Failure);

/// <summary>一次批量操作的汇总。</summary>
/// <param name="Outcomes">逐个目标的结果,顺序与发起时一致。</param>
public sealed record BatchResult(IReadOnlyList<BatchOutcome> Outcomes)
{
/// <summary>成功数。</summary>
public int SucceededCount => Outcomes.Count(o => o.Succeeded);

/// <summary>失败数。</summary>
public int FailedCount => Outcomes.Count(o => !o.Succeeded);

/// <summary>全部成功。</summary>
public bool AllSucceeded => FailedCount == 0;

/// <summary>失败的那些。</summary>
public IEnumerable<BatchOutcome> Failures => Outcomes.Where(o => !o.Succeeded);
}

/// <summary>
/// 批量执行器。
/// <para>
/// 存在的唯一理由是**逐个目标判定**:选中 10 个容器点停止,其中 2 个因为被依赖而失败时,
/// 界面要能说"成功 8、失败 2 —— postgres-main 被 api-gateway 依赖",
/// 而不是把整批说成"操作失败"。所以这里绝不 short-circuit:一个目标的异常
/// 只记在它自己名下,后面的照跑。
/// </para>
/// </summary>
public static class BatchRunner
{
/// <summary>
/// 对每个目标跑一遍 <paramref name="action" />,逐个记录成败。
/// </summary>
/// <param name="targets">目标与显示名。</param>
/// <param name="action">对单个目标的操作。</param>
/// <param name="onProgress">每完成一个回调一次(已完成数, 总数, 当前目标名)。</param>
/// <param name="cancellationToken">
/// 取消令牌。触发后**不再启动**新目标,已经发出去的那条等它自己结束 ——
/// 半路掐掉一条 <c>docker stop</c> 只会留下一个状态不明的容器。
/// </param>
public static async Task<BatchResult> RunAsync<T>(
IReadOnlyList<(T Target, string Name)> targets,
Func<T, CancellationToken, Task> action,
Action<int, int, string>? onProgress = null,
CancellationToken cancellationToken = default)
{
List<BatchOutcome> outcomes = [with(targets.Count)];
for (var i = 0; i < targets.Count; i++)
{
(var target, var name) = targets[i];
if (cancellationToken.IsCancellationRequested)
{
outcomes.Add(new(name, false, "已取消,这个目标没有执行。"));
continue;
}
onProgress?.Invoke(i, targets.Count, name);
try
{
await action(target, cancellationToken).ConfigureAwait(false);
outcomes.Add(new(name, true, null));
}
catch (DockerApiException ex)
{
outcomes.Add(new(name, false, ex.Message));
}
catch (DockerUnreachableException ex)
{
// 连接没了就没有必要继续戳后面的目标 —— 它们只会拿到同一条错误。
outcomes.Add(new(name, false, ex.Message));
for (var j = i + 1; j < targets.Count; j++)
{
outcomes.Add(new(targets[j].Name, false, "连接已断开,这个目标没有执行。"));
}
break;
}
catch (OperationCanceledException)
{
outcomes.Add(new(name, false, "已取消。"));
}
catch (Exception ex)
{
outcomes.Add(new(name, false, ex.Message));
}
}
onProgress?.Invoke(targets.Count, targets.Count, "");
return new(outcomes);
}
}
Loading