Repository Wiki
BeyondDimension/SteamTools

发布打包流水线(Tools.Publish)

BD.WTTS.Client.Tools.Publish 是 SteamTools 仓库中的一个独立命令行工具项目,负责将 Avalonia 客户端(BD.WTTS.Client.Avalonia.App)的发布产物按 RID(运行时标识符)矩阵重新组织、命名并打包为跨平台分发包(含 SCD 自包含与 FDE 依赖框架两种部署模式,以及 Linux 打包元数据)。

Purpose and Scope

本页覆盖该发布打包工具的完整机制:

  • 工具入口 Program.cs 如何通过反射自动发现并注册所有命令(ICommand 模式);
  • Constants.cs 中的路径约定、RID 矩阵、产物命名规则(GetFileName/GetPackPath)、版本来源与 DeconstructRuntimeIdentifier 解析逻辑;
  • SCD / FDE 两种部署模式的目录布局差异,以及 Linux(deb/rpm)打包常量 LinuxPackConstants。

本页不覆盖(留给兄弟页面):

  • CI/CD 工作流的触发、矩阵构建与上传策略——参见流水线/工作流相关页面;
  • 应用本体 BD.WTTS.Client.Avalonia.App 的构建与发布参数——参见客户端应用页面;
  • 安装器/引导器 BD.WTTS.Client.AppHost 的实现细节——参见 AppHost 相关页面。

Overview

SteamTools 需要同时面向 Windows(x64/x86/arm64)、macOS(x64/arm64)、Linux(x64/arm64/loongarch64)共 8 个 RID 发布。dotnet publish 原生输出只有 bin/Release/Publish/<rid> 这种扁平目录,产物命名也不符合发行需要。Tools.Publish 工具的作用是:

  1. 以命令行工具形式运行(RootCommand,基于 System.CommandLine 风格);
  2. 通过反射自动发现命令:所有实现 ICommand 接口的类型(在源码中表现为接口,见下文)都会被泛型调用 ICommand.AddCommand<T>() 注册到根命令,新增打包命令无需修改入口;
  3. 依据 Constants 中硬编码的路径与命名约定,把发布产物定位、打包并生成统一命名规则的压缩包/安装包文件名。

关键概念:

概念含义
RIDRuntime Identifier,如 win-x64、osx-arm64、linux-loongarch64
SCDSelf-Contained Deployment,自包含部署,携带 .NET 运行时
FDEFramework-Dependent Deployment,依赖框架部署
HARDCODED_APP_NAME不可变更的硬编码应用名 Steam++,用于文件/文件夹等不可变值
Trademark可变的展示名(AssemblyInfo.Trademark),用于产物文件名前缀

Architecture

Loading diagram...

架构说明:

  • 入口极薄:Program.cs 仅 13 行(含两条程序集级 StyleCop 抑制),所有命令注册完全由反射驱动。ICommand.AddCommand<T> 是一个静态方法,通过 MakeGenericMethod(x) 对每个发现的命令接口生成泛型调用,再用 m.Invoke(null, new object?[] { rootCommand, }) 把命令挂到根命令上。
  • 常量集中:Constants.cs 以内部接口(interface Constants)承载 const/static 成员,路径全部基于 ProjectUtils.ProjPath(仓库根)拼接,保证工具可在任意工作目录下运行。
  • 命令即接口:x.IsInterface && interfaceType.IsAssignableFrom(x) 这一过滤条件表明仓库中的命令类型被建模为接口(配合静态抽象成员的模式),由各命令自身决定打包行为;入口无需感知具体命令集合。

Core Flow

Loading diagram...

流程要点(逐条对应源码):

  1. 命令发现:interfaceType.Assembly.GetTypes() 取出定义 ICommand 的程序集中全部类型,Where(x => x != interfaceType && x.IsInterface && interfaceType.IsAssignableFrom(x)) 过滤出命令接口,Select(x => addMethod!.MakeGenericMethod(x)) 为每个命令生成 AddCommand<T> 泛型方法,最后 Array.ForEach(commands, m => m.Invoke(null, new object?[] { rootCommand, })) 批量注册。
  2. 执行分发:rootCommand.Parse(args).InvokeAsync() 返回 int(退出码),由顶层 return await 直接作为进程退出码。
  3. 产物定位:命令通过 Constants.DirPublish_SCD(src/BD.WTTS.Client.Avalonia.App/bin/Release/Publish)或 Constants.DirPublish_FDE(其下 FrameworkDependent 子目录)找到 dotnet publish 的输出。
  4. 打包输出位置随部署模式不同:GetPackPath 中 SCD 用 Path.Combine(item.DirectoryPath, "..", fileName)(产物目录上一级),FDE 用两级上级("..", ".."),这与两种模式目录嵌套深度差异一致。

Data Model / 产物命名与目录模型

产物命名与路径计算是本工具的核心业务逻辑,全部位于 Constants.cs:

csharp
1static string GetPackPath(AppPublishInfo item, string fileEx) 2{ 3 var fileName = GetFileName(item, fileEx); 4 var packPath = item.DeploymentMode switch 5 { 6 DeploymentMode.SCD => Path.Combine(item.DirectoryPath, "..", fileName), 7 DeploymentMode.FDE => Path.Combine(item.DirectoryPath, "..", "..", fileName), 8 _ => throw new ArgumentOutOfRangeException(nameof(item.DeploymentMode), item.DeploymentMode, null), 9 }; 10 return packPath; 11}

Source: Constants.cs

csharp
1static string GetFileName(AppPublishInfo item, string fileEx) 2{ 3 var name = item.DirectoryPath.Replace("-", "_"); 4 if (name.Contains("osx_")) name = name.Replace("osx_", "macos_"); 5 var version = GetVersion(); 6 var fileName = item.DeploymentMode switch 7 { 8 DeploymentMode.SCD => 9 $"{AssemblyInfo.Trademark.Replace(" ", "_")}_with_runtime_v{version}_{name}{fileEx}", 10 DeploymentMode.FDE => 11 $"{AssemblyInfo.Trademark.Replace(" ", "_")}_v{version}_{name}{fileEx}", 12 _ => throw new ArgumentOutOfRangeException(nameof(item.DeploymentMode), item.DeploymentMode, null), 13 }; 14 return fileName; 15}

Source: Constants.cs

设计意图:

  • 目录名即 RID:item.DirectoryPath 直接来源于 RID 命名的发布目录(如 win-x64),因此 Replace("-", "_") 得到 win_x64 这样的标识片段。
  • osx → macos 改名:用户可读性优先,macos_arm64 比 osx_arm64 更符合最终用户认知(Apple Silicon 用户更熟悉 "Apple silicon/macOS" 而非 RID 术语)。
  • _with_runtime_ 后缀:让用户一眼分辨自包含(带运行时、体积大)与依赖框架(需自行安装 .NET)两种发行包,避免误下。
  • AssemblyInfo.Trademark.Replace(" ", "_"):展示名中的空格替换为下划线以保证文件名跨平台安全;版本号取 AssemblyInfo.Version(GetVersion() 直接返回该值)。
  • 未知模式的防御性抛错:_ => throw new ArgumentOutOfRangeException(...) 保证新增部署模式时若未更新命名逻辑会立即失败(fail-fast),而不是静默产出错误命名。

RID 解析:DeconstructRuntimeIdentifier(string rid) 把 RID 拆为 (Platform, DeviceIdiom, Architecture) 三元组:

csharp
1static (Platform Platform, DeviceIdiom DeviceIdiom, Architecture Architecture) DeconstructRuntimeIdentifier(string rid) 2{ 3 (Platform Platform, DeviceIdiom DeviceIdiom, Architecture Architecture) info = default; 4 var array = rid.Split('-', StringSplitOptions.RemoveEmptyEntries); 5 if (array.Length == 2) 6 { 7 switch (array[0]?.ToLower()) 8 { 9 case "win": 10 info.Platform = Platform.Windows; 11 info.DeviceIdiom = DeviceIdiom.Desktop; 12 break; 13 case "linux": 14 info.Platform = Platform.Linux; 15 info.DeviceIdiom = DeviceIdiom.Desktop; 16 break; 17 case "osx": 18 info.Platform = Platform.Apple; 19 info.DeviceIdiom = DeviceIdiom.Desktop; 20 break; 21 } 22 ...

Source: Constants.cs

该解析支持的平台段:win/linux/osx(均映射为 Desktop 设备形态);架构段:x86/x64/arm/arm64/loongarch64(ArchToString 反向映射亦包含 Architecture.LoongArch64,见 Constants.cs)。

RID 矩阵(all_rids)

csharp
1static readonly string[] all_rids = new[] { 2 "win-x64", "win-x86", "win-arm64", 3 "osx-x64", "osx-arm64", 4 "linux-x64", "linux-arm64", "linux-loongarch64", 5};

Source: Constants.cs

8 个 RID 构成完整发布矩阵:Windows 3 个架构、macOS 2 个架构(Intel + Apple Silicon)、Linux 3 个架构(含国产龙芯 loongarch64)。

Linux 打包常量(LinuxPackConstants)

csharp
1interface LinuxPackConstants 2{ 3 const string TargetName = Constants.HARDCODED_APP_NAME; 4 const string PackagePrefix = TargetName; 5 const string PackageName = PackagePrefix; 6 const string Prefix = "/usr/share/" + PackagePrefix; 7 const string Release = "0"; 8 const bool CreateUser = false; 9 const string UserName = Constants.HARDCODED_APP_NAME; 10 const bool InstallService = false; 11 const string ServiceName = PackagePrefix; 12 const string RpmVendor = AssemblyInfo.Company; 13 const string Description = AssemblyInfo.Description; 14 const string Url = "https://steampp.net"; 15 const string PreInstallScript = null!; 16 const string PostInstallScript = null!; 17 const string PreRemoveScript = null!; 18 const string PostRemoveScript = null!; 19 const string FileNameDesktop = Constants.HARDCODED_APP_NAME + ".desktop"; 20 const string DebMaintainer = AssemblyInfo.Company; 21 const string DebSection = "misc"; 22 const string DebPriority = "extra"; 23 const string DebHomepage = Url; 24 static readonly string dotnet_runtime = $"dotnet-runtime-{Environment.Version.Major}.{Environment.Version.Minor}"; 25 static readonly string aspnetcore_runtime = $"aspnetcore-runtime-{Environment.Version.Major}.{Environment.Version.Minor}"; 26}

Source: Constants.cs

设计意图:这是 deb/rpm 打包所需元数据的单一事实来源——包名、安装前缀 /usr/share/steampp、desktop 文件名 Steam++.desktop(与项目内 Steam++.desktop 对应)、deb 的 maintainer/section/priority/homepage、rpm 的 vendor。dotnet_runtime/aspnetcore_runtime 按运行工具的当前 .NET 版本(Environment.Version)动态计算依赖包名,用于 FDE 包声明对系统 .NET 运行时的依赖,避免硬编码版本导致升级 .NET 后忘改依赖。四个安装/卸载脚本槽位均置 null!,表示当前打包流程不注入额外脚本钩子。

目录结构模型

Loading diagram...
  • SCD 模式:每个 RID 的产物在 Publish/<rid>/,压缩包输出到该目录上一级(即 Publish/ 本身),多个 RID 的包并列放在 Publish/ 下。
  • FDE 模式:产物在 Publish/FrameworkDependent/<rid>/,压缩包输出两级上级,即与 SCD 包同处 Publish/ 层,两类产物可并列分发。

Usage Examples

工具入口:反射驱动的命令自动注册

csharp
1var rootCommand = new RootCommand($"{AssemblyInfo.Product} Publish Tools"); 2var interfaceType = typeof(ICommand); 3var addMethod = interfaceType.GetMethod(nameof(ICommand.AddCommand), BindingFlags.Static | BindingFlags.Public); 4var commands = interfaceType.Assembly.GetTypes(). 5 Where(x => x != interfaceType && x.IsInterface && interfaceType.IsAssignableFrom(x)). 6 Select(x => addMethod!.MakeGenericMethod(x)). 7 ToArray(); 8Array.ForEach(commands, m => m.Invoke(null, new object?[] { rootCommand, })); 9return await rootCommand.Parse(args).InvokeAsync();

Source: Program.cs

逐行解读:

  • new RootCommand($"{AssemblyInfo.Product} Publish Tools"):根命令描述由程序集产品名动态拼接(如 "Steam++ Publish Tools"),保证改名时帮助文本自动跟随。
  • interfaceType.GetMethod(nameof(ICommand.AddCommand), BindingFlags.Static | BindingFlags.Public):拿到静态泛型方法 ICommand.AddCommand<T>() 的 MethodInfo。
  • Where(...):从 ICommand 所在程序集中筛出除自身外所有实现 ICommand 的接口——命令被建模为接口(配合 C# 静态抽象接口成员),工具通过该约定实现"零修改扩展"。
  • MakeGenericMethod(x) + Invoke(null, ...):对每个命令接口实例化 AddCommand<T> 并传入 rootCommand 完成挂载;首个参数为 null 因其为静态方法。
  • return await ...InvokeAsync():把命令执行结果(退出码)直接作为进程返回值,供 CI 判断成败。

关键路径常量与调试产物定位

csharp
1interface Constants 2{ 3 const string ProjectDir_AvaloniaApp = "BD.WTTS.Client.Avalonia.App"; 4 const string ProjectDir_AppHost = "BD.WTTS.Client.AppHost"; 5 const string windowssdkver = "10.0.19041.0"; 6 7 static string DebugRuntimeConfigPath => Path.Combine(ProjectUtils.ProjPath, "src", ProjectDir_AvaloniaApp, "bin", "Debug", $"net{Environment.Version.Major}.{Environment.Version.Minor}-windows{windowssdkver}", runtimeconfigjsonfilename); 8 9 static string DirPublish_SCD => Path.Combine(ProjectUtils.ProjPath, "src", ProjectDir_AvaloniaApp, "bin", "Release", "Publish"); 10 11 static string DirPublish_FDE => Path.Combine(ProjectUtils.ProjPath, "src", ProjectDir_AvaloniaApp, "bin", "Release", "Publish", "FrameworkDependent"); 12 ...

Source: Constants.cs

解读:

  • 所有路径基于 ProjectUtils.ProjPath(仓库根)派生,工具不依赖当前工作目录,可在 CI 任意 checkout 路径下运行。
  • DebugRuntimeConfigPath 指向 Debug 构建的 Steam++.runtimeconfig.json,配合 windowssdkver = "10.0.19041.0" 与 net{major}.{minor} 动态拼接 TFM 路径(如 net8.0-windows10.0.19041.0)——用于在打包时读取/校正运行时配置。
  • 文件名常量 runtimeconfigjsonfilename = "Steam++.runtimeconfig.json"、depsjsonfilename = "Steam++.deps.json"、exefileName = "Steam++.exe" 均使用硬编码应用名 HARDCODED_APP_NAME = "Steam++",源码注释明确标注"此值不可更改!"(见 Constants.cs),因为它们被用于缓存目录、协议注册等不可变值。

打包时排除运行期数据目录

csharp
1static readonly string[] ignoreDirNames = new[] 2{ 3 IOPath.DirName_AppData, 4 IOPath.DirName_Cache, 5}; 6 7[MethodImpl(MethodImplOptions.AggressiveInlining)] 8static async ThreadTask InBackground(Action action, bool longRunning = false) 9{ 10 TaskCreationOptions options = TaskCreationOptions.DenyChildAttach; 11 12 if (longRunning) 13 { 14 options |= TaskCreationOptions.LongRunning | TaskCreationOptions.PreferFairness; 15 } 16 17 await ThreadTask.Factory.StartNew(action, CancellationToken.None, options, TaskScheduler.Default).ConfigureAwait(false); 18}

Source: Constants.cs

解读:

  • ignoreDirNames 复用 IOPath.DirName_AppData/IOPath.DirName_Cache(与应用层共享的目录名常量),打包遍历时跳过 AppData 与 Cache 目录——防止把应用运行期产生的用户数据/缓存误打进分发包。
  • InBackground 是打包并发执行辅助方法:DenyChildAttach 防止任务内再派生子任务干扰调度;longRunning=true 时追加 LongRunning | PreferFairness 提示调度器为长耗时压缩/复制任务创建专用线程而非占用线程池;ConfigureAwait(false) 避免上下文回切。MethodImplOptions.AggressiveInlining 表明它是高频小任务包装器。

Configuration Options

本工具无独立配置文件,全部"配置"以代码内常量/静态属性形式集中管理:

常量/属性类型值(默认)说明
ProjectDir_AvaloniaAppconst string"BD.WTTS.Client.Avalonia.App"被打包的 Avalonia 应用项目目录名
ProjectDir_AppHostconst string"BD.WTTS.Client.AppHost"安装器/引导器项目目录名
windowssdkverconst string"10.0.19041.0"Windows TFM SDK 版本,用于定位 Debug 产物
DirPublish_SCDstatic stringsrc/BD.WTTS.Client.Avalonia.App/bin/Release/PublishSCD 发布产物根目录
DirPublish_FDEstatic string上述目录下 FrameworkDependentFDE 发布产物根目录
DebugRuntimeConfigPathstatic stringbin/Debug/net{ver}-windows10.0.19041.0/Steam++.runtimeconfig.jsonDebug 运行时配置文件路径
runtimeconfigjsonfilenameconst string"Steam++.runtimeconfig.json"运行时配置文件名
depsjsonfilenameconst string"Steam++.deps.json"依赖清单文件名
exefileNameconst string"Steam++.exe"主程序可执行文件名
HARDCODED_APP_NAMEconst string"Steam++"不可变硬编码应用名(文件/目录/协议等不可变值)
all_ridsstatic string[]win-x64/x86/arm64, osx-x64/arm64, linux-x64/arm64/loongarch64完整发布 RID 矩阵(8 项)
ignoreDirNamesstatic string[]{IOPath.DirName_AppData, IOPath.DirName_Cache}打包时忽略的运行期数据目录
LinuxPackConstants.TargetName/PackageName/PackagePrefixconst string"Steam++"deb/rpm 包名与目标名
LinuxPackConstants.Prefixconst string"/usr/share/Steam++"Linux 安装前缀
LinuxPackConstants.Releaseconst string"0"包 Release 号
LinuxPackConstants.CreateUser / InstallServiceconst boolfalse不创建专用用户/不安装系统服务
LinuxPackConstants.RpmVendor / DebMaintainerconst stringAssemblyInfo.Company包维护者/厂商
LinuxPackConstants.DebSection / DebPriorityconst string"misc" / "extra"deb 分类与优先级
LinuxPackConstants.Url / DebHomepageconst string"https://steampp.net"项目主页
LinuxPackConstants.dotnet_runtimestatic stringdotnet-runtime-{major}.{minor}FDE 包对 .NET 运行时的 deb/rpm 依赖名(按工具自身 .NET 版本动态计算)
LinuxPackConstants.aspnetcore_runtimestatic stringaspnetcore-runtime-{major}.{minor}ASP.NET Core 运行时依赖名
LinuxPackConstants.PreInstallScript 等 4 个脚本槽位const stringnull!当前不注入安装/卸载脚本

API Reference

以下为 Constants(internal interface,源码级 API)中的关键方法签名与行为:

GetPackPath(AppPublishInfo item, string fileEx): string

计算最终打包产物的完整输出路径。

参数:

  • item (AppPublishInfo):包含 DirectoryPath(RID 发布目录)与 DeploymentMode(SCD/FDE)
  • fileEx (string):产物扩展名(如 .7z、.deb)

返回: 完整打包路径。SCD 为 item.DirectoryPath/../<fileName>,FDE 为 item.DirectoryPath/../../<fileName>。

抛出:

  • ArgumentOutOfRangeException:DeploymentMode 不是 SCD 或 FDE 时(fail-fast)。

Source: Constants.cs

GetFileName(AppPublishInfo item, string fileEx): string

生成分发包文件名,规则:{Trademark 去空格}[_with_runtime]_v{version}_{目录名(- 换 _,osx 换 macos)}{扩展名}。

参数: 同 GetPackPath。

返回: 例如 Steam++_with_runtime_v3.0.0_macos_arm64.7z(SCD)或 Steam++_v3.0.0_win_x64.7z(FDE)形式。

抛出:

  • ArgumentOutOfRangeException:DeploymentMode 未知。

Source: Constants.cs

GetVersion(): string

返回 AssemblyInfo.Version,即程序集版本号,用作产物文件名中的版本段。

Source: Constants.cs

ArchToString(Architecture architecture): string

把 System.Runtime.InteropServices.Architecture 枚举映射为小写字符串:arm/arm64/x64/x86/loongarch64;未知值抛 ArgumentOutOfRangeException。

Source: Constants.cs

DeconstructRuntimeIdentifier(string rid): (Platform, DeviceIdiom, Architecture)

把 RID 拆解为平台/设备形态/架构三元组;仅支持两段式 RID(平台-架构)。无法识别的平台/架构段保持 default(元组默认值),不抛异常。

Source: Constants.cs

InBackground(Action action, bool longRunning = false): ThreadTask

把同步打包动作调度到后台线程执行;longRunning=true 时使用 LongRunning | PreferFairness | DenyChildAttach 任务选项。

Source: Constants.cs

Failure Modes, Edge Cases & Concurrency

依据源码可验证的边界与防御行为:

  • 未知部署模式 fail-fast:GetPackPath/GetFileName 的 switch 均以 throw new ArgumentOutOfRangeException(nameof(item.DeploymentMode), ...) 兜底。设计意图:新增部署模式时强制开发者显式更新命名与路径规则,杜绝静默产出错误命名的分发包。
  • 未知架构 fail-fast:ArchToString 对未映射的 Architecture 值抛 ArgumentOutOfRangeException,防止产出命名不完整的包。
  • RID 解析静默降级:DeconstructRuntimeIdentifier 对不认识的段(例如未来出现新平台前缀)不抛异常而是保持默认值——这是刻意的宽松解析,让新 RID 至少能参与平台无关的处理步骤;但两段式之外(array.Length != 2)的三段 RID(如 linux-musl-x64)同样返回默认元组,调用方需自行校验。
  • osx 前缀重写:仅当目录名包含 osx_(已先做过 -→_ 替换)才替换为 macos_,避免误伤其他片段。
  • 并发任务调度隔离:InBackground 使用 DenyChildAttach 防止子任务逃逸到外层延续、TaskScheduler.Default 固定线程池调度,多 RID 并行压缩时行为可预测;ConfigureAwait(false) 避免 UI/上下文回切(工具为纯命令行,无同步上下文,这是防御性写法)。
  • 运行期数据防泄漏:ignoreDirNames 确保打包不吞入 AppData/Cache。
  • null! 脚本槽位:四个安装/卸载脚本常量声明为 null!——若未来 deb/rpm 打包逻辑直接拼接这些脚本路径而不判空,将产生空路径异常;当前无注入脚本需求,属于"已知留白"。

Performance & Operational Notes / Extension Points

扩展点(零修改入口):新增打包命令只需新建一个实现 ICommand 的接口(静态 AddCommand<T> 约定),Program.cs 的反射扫描即自动注册——入口对命令集合完全开放闭合(OCP)。这也是把命令建模为接口而非类的直接原因:静态抽象接口成员(C# 11 static abstract members in interfaces)使得 AddCommand<T>() where T : ICommand 可在无需实例化的前提下挂载命令。

版本一致性:dotnet_runtime/aspnetcore_runtime 依赖名按 Environment.Version(工具自身运行时)计算,因此用与目标应用相同/匹配的 .NET SDK 运行本工具是产出正确 FDE 依赖声明的前提;升级 .NET 后无需改代码,但需确保 CI 中工具与被发布应用的 TFM 一致。

运行方式:该工具是独立控制台程序(Program.cs 顶层语句 + return await ...InvokeAsync() 返回退出码),天然适配 CI 脚本调用;产物统一落到 Publish/ 层,便于 CI 单点收集上传(CI 工作流的矩阵构建与上传策略由兄弟页面描述)。

产物命名含义速查:

文件名片段含义
Steam++(Trademark 前缀)可变展示名,空格转下划线
_with_runtime_自包含部署(SCD),内置 .NET 运行时
无 _with_runtime_依赖框架部署(FDE),需系统 .NET
_v{version}_程序集版本号
macos_ / win_ / linux_平台(osx RID 已重写为 macos)
x64 / x86 / arm64 / loongarch64 等CPU 架构

相邻主题:客户端应用构建(BD.WTTS.Client.Avalonia.App)、安装器(BD.WTTS.Client.AppHost)与 CI 工作流由各自兄弟页面覆盖,本页不展开。

Sources

(2 files)