Repository Wiki
BeyondDimension/SteamTools

应用启动流程与命令行协议

应用启动流程与命令行协议是 Watt Toolkit(SteamTools)客户端从进程入口到主程序就绪之间的完整引导机制:它统一处理进程角色判定(主进程 / 命令行工具进程 / IPC 子进程)、命令行参数解析、自定义 URL 协议(CUSTOM_URL_SCHEME)激活、以及按区域(Region)划分的启动管线(平台先决条件 → 自定义应用程序域 → 运行时配置 → 文件系统初始化)。核心实现在 src/BD.WTTS.Client/Startup/ 目录下的 Startup 抽象分部类中。

Purpose and Scope

本页面覆盖以下内容:

  • Startup 类的构造、单例实例(instance)与 StartAsync() 主启动管线的全部区域(Region):PlatformPrerequisite、CustomAppDomain、RuntimeConfiguration、InitFileSystem。
  • 命令行协议:GetCommandLineArgs() 中的进程角色判定规则、-clt 命令行模式、-clt main 禁用规则、help/main 兜底参数。
  • 自定义 URL 协议:GetArgsByCustomUrlScheme / GetArgsByCustomUrlSchemeCore 将 URL 转换为命令行参数的逻辑,以及 Windows UWP 协议激活(AppInstance.GetActivatedEventArgs())的接入方式。
  • 启动性能追踪:WatchTrace 打点与 STARTUP_WATCH_TRACE/DEBUG 编译符号。
  • 与启动入口相关的子进程用法(IPC 子进程管道名参数、更新插件 Base64Url 参数)作为协议消费方的参考。

以下相关主题有意留给兄弟页面,本页仅交叉引用:

  • IPC 管道与子进程服务全貌:参见 IPCSubProcessService 相关页面。
  • 更新插件(Plugins.Update)的完整替换流程:参见更新子系统的页面。
  • Startup.Host.cs 中宿主与 DI 容器装配细节:参见对应宿主页面。

Overview

Watt Toolkit 是一个多形态客户端:它既可能是用户双击图标启动的主进程(GUI),也可能是被同一可执行文件以 -clt 参数拉起的命令行工具进程,还可能是操作系统通过自定义 URL 协议(例如网页上的"一键激活"按钮)或 UWP 协议激活唤起的实例。为了让同一个 Startup 引导代码同时服务这些场景,设计上采取了"先归一化参数,再按角色分流"的策略:

  1. 参数归一化:无论参数来自 Main(string[] args)、Environment.GetCommandLineArgs()、UWP 激活事件,还是自定义 URL Scheme,最终都折叠为一个 string[]。
  2. 角色判定:IsMainProcess(空参数即主进程)、IsConsoleLineToolProcess(首参数为 clt_ 常量)等只读属性决定后续分支。
  3. 启动管线:StartAsync() 按顺序执行平台兼容性检查、本机库解析器注入、序列化格式化器注册、文件系统目录初始化等区域化步骤,每步都有 WatchTrace 打点。

这种设计的关键意图是:命令行协议是整个应用对外唯一的"激活语言",URL 协议、开机自启、IPC、命令行工具全部翻译成同一套参数词汇,从而让下游逻辑(Startup.Host.cs 中的 switch (args[0]) 等)无需感知激活来源。

Architecture

Loading diagram...

架构要点说明:

  • 入口与单例:Startup 构造函数接收 Main 传入的 string[]? args(从其他位置启动时传 null),并把自身写入静态 instance,使后续代码可以通过单例访问启动状态。
  • 归一化层只有一个出口:GetCommandLineArgs() 是唯一的参数出口,其返回 null 是一个特殊协议——表示"应立即退出进程"。
  • 管线区域按 #region 划分,每个区域结束都有 WatchTrace.Record(...) 打点,便于在 DEBUG/STARTUP_WATCH_TRACE 构建下量化各阶段耗时。
  • Host 层消费参数:参数解析与角色判定完成后,Startup.Host.cs 中的 switch (args[0])(宿主/IPC 子进程分支入口)继续消费归一化后的参数,本页只说明到该边界为止。

进程角色与参数词汇表

启动协议依赖一组常量与属性(定义于 Startup 及其分部文件中,clt_、command_main、help_ 为常量标识符):

标识符类别语义(依据源码证据)
clt_常量命令行工具模式开关;源码注释以 -clt main 禁止使用此参数 描述其值形态
command_main常量主进程启动指令;非命令行进程时返回 new[] { command_main } 作为"启动主进程的参数"
help_常量帮助指令;-clt 后无参数时以 new[] { help_ } 兜底
IsMainProcess属性args.IsEmpty 时为 true,即无任何参数的普通 GUI 启动
IsConsoleLineToolProcess属性!IsMainProcess && args[0] == clt_(忽略大小写)
IsDesignMode属性XAML 设计器模式,直接返回空参数数组
IPlatformService.IPCRoot.CommandName平台常量IPC 子进程指令;命中时把 ModuleName 设为 IPCRoot.moduleName
IPlatformService.SystemBootRunArguments平台常量开机自启(UWP StartupTask)专用参数串,按空格拆分
Constants.CUSTOM_URL_SCHEME全局常量自定义 URL 协议前缀,拼接 "args" / "args/" 后用于识别 URL 传参

核心流程:GetCommandLineArgs() 逐步解析

GetCommandLineArgs() 是启动协议的心脏,其完整控制流如下(源码位于 Startup.cs):

  1. 设计器短路:IsDesignMode 直接返回空数组,避免在 XAML 预览器中执行真实启动逻辑。
  2. UWP 激活分支(仅 Windows + DesktopBridge.IsRunningAsUwp):调用 AppInstance.GetActivatedEventArgs() 读取激活事件:
    • ActivationKind.Protocol → HandleProtocolActivation(args) 从 Uri 提取并覆盖 this.args;
    • ActivationKind.StartupTask → 用 IPlatformService.SystemBootRunArguments.Split(' ') 覆盖参数,使开机自启与手动启动走同一条词法路径。
  3. 参数来源回退:优先使用构造传入的 this.args;否则取 Environment.GetCommandLineArgs() 并用 args[1..] 剥离首元素(进程可执行文件路径,源码注释明确说明)。
  4. 自定义 URL 协议解析:非 UWP 场景下,若首参数命中 CUSTOM_URL_SCHEME 前缀,则以解析结果整体替换参数。
  5. 角色判定:IsMainProcess = args.IsEmpty;IsConsoleLineToolProcess = !IsMainProcess && args[0] == clt_。
  6. 三种出口:
    • -clt main → 返回 null(StartAsync 中直接 return 0,进程静默退出);
    • -clt 后无其他参数 → 返回 { help_ }(源码注释:"无参数且不为主进程的清空使用 help 参数");
    • 非 -clt(含空参数)→ 返回 { command_main }。

时序图:从 Main 到 Host 分流

Loading diagram...

自定义 URL 协议(CUSTOM_URL_SCHEME)

CUSTOM_URL_SCHEME 让浏览器或系统唤起应用时也能携带命令行语义。核心实现:

csharp
1const string customUrlSchemeArgs = $"{Constants.CUSTOM_URL_SCHEME}args"; 2const string customUrlSchemeArgs2 = $"{Constants.CUSTOM_URL_SCHEME}args/"; 3 4static string[]? GetArgsByCustomUrlScheme(string urlString) 5{ 6 if (urlString.StartsWith(customUrlSchemeArgs2, StringComparison.OrdinalIgnoreCase)) 7 { 8 return GetArgsByCustomUrlSchemeCore(urlString[customUrlSchemeArgs2.Length..]); 9 } 10 else if (urlString.StartsWith(customUrlSchemeArgs, StringComparison.OrdinalIgnoreCase)) 11 { 12 return GetArgsByCustomUrlSchemeCore(urlString[customUrlSchemeArgs.Length..]); 13 } 14 return null; 15} 16 17static string[] GetArgsByCustomUrlSchemeCore(string value) 18{ 19 var args = HttpUtility.UrlDecode(value).Split(' ', StringSplitOptions.RemoveEmptyEntries); 20 return args; 21}

Startup.cs

设计意图:

  • 两种前缀(带尾斜杠与不带)都要支持,因为不同操作系统/浏览器在拼接协议 URL 时对斜杠的处理不一致;代码先匹配长的 args/ 再匹配短的 args,避免把 / 留在解码结果开头。
  • URL 解码 + 空格切分:把 st://args/... 之后的负载视为一个被 URL 编码的"虚拟命令行",用 HttpUtility.UrlDecode 还原后按空格拆分,天然支持含空格参数与多参数传递。
  • 返回 null 表示"不是本协议的 URL",调用方据此跳过替换,保持原始 args 不变。

Windows 上 UWP 形态通过事件参数接入同一套解析:

csharp
1static string[]? HandleProtocolActivation(ProtocolActivatedEventArgs args) 2{ 3 var uri = args.Uri; 4 if (uri != null) 5 { 6 var urlString = uri.ToString(); 7 var argsByCustomUrlScheme = GetArgsByCustomUrlScheme(urlString); 8 if (argsByCustomUrlScheme != null) 9 { 10 return argsByCustomUrlScheme; 11 } 12 } 13 return null; 14}

Startup.cs

在 GetCommandLineArgs() 中,UWP 场景会把 analysisCustomUrlSchemeArgs 置为 false(源码注释:"已由 HandleProtocolActivation 处理"),防止同一 URL 被二次解析。

StartAsync() 启动管线详解

StartAsync()(Startup.cs L197 起)按 #region 划分为串行阶段,逐段说明:

0. 调试前置检查

  • DEBUG + Windows 下强制校验主线程 ApartmentState.STA,否则抛出 ArgumentOutOfRangeException,把 UI 线程模型问题在开发期暴露。
  • DEBUG 下输出 BaseDirectory: {AppContext.BaseDirectory},便于核对实际加载根目录。

1. PlatformPrerequisite 平台先决条件

csharp
1if (!IsDesignMode) // 仅在非设计器中执行 2{ 3#if WINDOWS // Windows 需要检查兼容性 4 if (args.Length == 0 && !IsCustomEntryPoint && !CompatibilityCheck(AppContext.BaseDirectory)) 5 return 0; 6#elif MACOS // macOS 需要初始化 NSApplication 7 NSApplication.Init(); 8#endif 9 WatchTrace.Record("PlatformPrerequisite"); 10}

Startup.cs

兼容性检查仅在"GUI 主启动"(args.Length == 0)且非自定义入口点时执行——因为命令行/子进程模式不需要 UI 运行环境。检查失败直接 return 0 静默退出。

2. CustomAppDomain 自定义应用程序域

本阶段解决"本机库(native library)找不到"与"程序集回退加载"两大难题:

csharp
1// 调试时移动本机库到 native,通常指定了单个 RID(RuntimeIdentifier) 2// 后本机库将位于程序根目录上否则将位于 runtimes 文件夹中 3GlobalDllImportResolver.MoveFiles(); 4// 监听当前应用程序域的程序集加载 5AppDomain.CurrentDomain.AssemblyLoad += (_, args) 6 => CurrentDomain_AssemblyLoad(args.LoadedAssembly); 7static void CurrentDomain_AssemblyLoad(Assembly loadedAssembly) 8{ 9 // 使用 native 文件夹导入解析本机库 10 try 11 { 12 NativeLibrary.SetDllImportResolver(loadedAssembly, GlobalDllImportResolver.Delegate); 13 } 14 catch 15 { 16 // 每个程序集只能注册一个解析程序。 尝试注册第二个解析程序失败并出现 InvalidOperationException。 17 } 18} 19var assemblies = AppDomain.CurrentDomain.GetAssemblies(); 20foreach (var assembly in assemblies) 21 CurrentDomain_AssemblyLoad(assemblies);

Startup.cs

  • AssemblyLoad 事件对未来加载的程序集生效;随后手动遍历 GetAssemblies() 对已加载的程序集补注册,保证解析器覆盖完整。
  • catch 块的注释完整引用了 .NET 官方文档对 SetDllImportResolver 重复注册抛 InvalidOperationException 的说明——这是有意的幂等防御:重复注册不视为错误。

程序集回退解析(AssemblyResolve)则从 typeof(Startup).Assembly.Location 推导探测目录(失败时回退 AppContext.BaseDirectory),对 *.resources 卫星程序集直接返回 null(源码注释说明默认资源已包含,可通过反射验证),其余按 {程序集名}.dll 在根目录查找并 Assembly.LoadFrom。

3. RuntimeConfiguration 运行时配置

csharp
1// 注册 MemoryPack 某些自定义类型的格式化,如 Cookie, IPAddress, RSAParameters 2HashSet<Type> types = new(); 3MemoryPackFormatters.OnRegister = type => types.Add(type); 4MemoryPackFormatterProvider.Register<MemoryPackFormatters>(); 5MemoryPackFormatters.OnRegister = null; 6InitNJsonSerializer(types); 7 8// 添加 .NET Framework 中可用的代码页提供对编码提供程序 9Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); 10 11// fix The request was aborted: Could not create SSL/TLS secure channel 12ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls13;

Startup.cs

三个动作各自对应的故障模式:MemoryPack 缺格式化器导致反序列化异常;中文环境 GB2312 等代码页缺失;以及旧系统默认协议未含 TLS 1.2/1.3 导致 HTTPS 失败(注释中的原报错信息直接保留了事故线索)。OnRegister 钩子用完即置 null,避免向后续注册泄露回调。

4. InitFileSystem 初始化文件系统

按平台设置 IOPath.AppDataDirectory 与 IOPath.CacheDirectory(源码注释明确):macOS/iOS 走 MacCatalystFileSystem.InitFileSystem(),Linux 走 LinuxFileSystem.InitFileSystem(),Windows 在 isRunningAsUwp(来自第 0 步 DesktopBridgeHelper.Init() 的返回值)时走 WindowsRuntimeFileSystem.InitFileSystem()。整个调用被 try 包裹以容忍非关键目录初始化失败。

Usage Examples

协议消费方 1:IPC 子进程入口

IPC 子进程直接把 args[0] 当作管道名使用,是"主进程拉起子进程"链路的一部分:

csharp
1if (args.Length == 0) 2 return (int)CommandExitCode.EmptyArrayArgs; 3var pipeName = args[0]; 4if (string.IsNullOrWhiteSpace(pipeName))

IPCSubProcessService.cs

与 Startup 中 args.FirstOrDefault() == IPlatformService.IPCRoot.CommandName 的判定相呼应:主进程通过 IPC 指令启动子进程后,子进程的参数协议是"首参数 = 管道名"。空参数以 CommandExitCode.EmptyArrayArgs 退出码显式失败,而不是抛异常。

协议消费方 2:更新插件入口(Base64Url 编码路径)

更新子进程用 Base64Url 编码规避"路径含空格/中文导致命令行拆分错误"的经典问题:

csharp
//Directory.Move() var sourcePath = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(args[0])); var destPath = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(args[1]));

Program.cs

源码保留的 //Directory.Move() 注释暗示了设计演进:先用托管 API 尝试,后改为独立进程替换,以绕过文件占用与权限限制。

入口模式:从 Main 构造 Startup

csharp
1public Startup(string[]? args = null) 2{ 3 // 从 Main 函数中启动传递 string[] args,从其他地方启动传递 null 4 this.args = args; 5 instance = this; 6}

Startup.cs

args = null 允许从测试或非 Main 路径构造 Startup,此时 GetCommandLineArgs() 会自动回退到 Environment.GetCommandLineArgs()[1..]。

Configuration Options

启动流程的"配置"以编译符号与平台常量为主,而非运行时配置文件:

符号 / 常量类型默认(未定义时)作用
STARTUP_WATCH_TRACE编译符号未定义(DEBUG 下自动生效)开启 WatchTrace.Start()/Record() 启动耗时打点
DEBUG编译符号Release 未定义额外输出 STA 校验、loadasm 程序集加载日志、GlobalDllImportResolver.MoveFiles() 调试移动
WINDOWS / MACOS / LINUX / MACCATALYST / IOS / ANDROID平台符号按目标平台决定 InitFileSystem 与兼容性检查的平台分支
IsCustomEntryPoint属性—为 true 时跳过 Windows 兼容性检查(供特殊入口进程复用管线)
Constants.CUSTOM_URL_SCHEME全局常量—自定义协议前缀,URL 传参的词法基础
IPlatformService.SystemBootRunArguments平台常量—UWP 开机自启的固定参数串
ServicePointManager.SecurityProtocol静态属性平台默认启动时强制 Tls12 | Tls13

API Reference

Startup.GetCommandLineArgs(): string[]?

返回值:归一化后的命令行参数数组。返回 null 是协议级"退出指令"(当前唯一触发条件:-clt main),StartAsync 据此 return 0。

副作用:设置 IsMainProcess、IsConsoleLineToolProcess,可能在 UWP 激活分支中覆写 this.args。

Startup.GetArgsByCustomUrlScheme(urlString: string): string[]?

参数:urlString —— 完整 URL 或命令行首参数。

返回值:命中 CUSTOM_URL_SCHEME + args/args/ 前缀时返回解码切分后的参数数组;否则 null。

Startup.HandleProtocolActivation(args: ProtocolActivatedEventArgs): string[]?(仅 Windows)

参数:UWP 协议激活事件参数,从 args.Uri 提取 URL。

返回值:解析成功返回参数数组;Uri 为 null 或非本协议返回 null。

Startup.StartAsync(): Task<int>

返回值:进程退出码。0 表示正常路径(含"应退出"路径)。公开虚方法(public virtual),允许派生类(如各平台 App)扩展启动行为。

内部顺序:WatchTrace.Start() → GetCommandLineArgs() → IPC 子进程 ModuleName 判定 → PlatformPrerequisite → CustomAppDomain → RuntimeConfiguration → InitFileSystem → (后续 Host 装配,见兄弟页面)。

Failure Modes, Edge Cases & Concurrency

  • -clt main 静默退出:该组合被显式禁止(源码注释:"禁止使用此参数"),以返回 null → return 0 结束,不弹窗不写日志。这是一个防误用护栏:main 只允许作为主进程的内部指令。
  • 首参数是进程路径的剥离:Environment.GetCommandLineArgs()[0] 为可执行文件路径,代码用 args[1..] 裁掉;若忽略此点,路径会被误判为 clt_ 比较,导致角色判定错误。
  • 空参数 + 非主进程 → help 兜底:-clt 后无参数时返回 { help_ } 而不是空数组,保证下游 switch 必有匹配分支,这是"永远不产生无意义参数"的防御式设计。
  • SetDllImportResolver 重复注册:对已注册程序集二次注册抛 InvalidOperationException,被 catch 吞掉(源码注释引用官方文档),换来幂等性。
  • 程序集解析失败仅 DEBUG 记录:AssemblyResolve 回退查找失败时静默返回 null,仅在 DebugConsole.WriteAssemblyResolve 开启时输出 asm-resolve fail。
  • URL 前缀歧义:必须先匹配 args/(长前缀)再匹配 args(短前缀),否则尾斜杠会混入解码结果首参数。
  • STA 线程校验仅在 DEBUG + Windows:Release 构建不校验,避免发布版因线程模型异常提前崩溃,代价是问题在开发期才暴露。
  • 并发性:启动管线为单线程串行执行;AssemblyLoad 事件回调在程序集加载时触发,代码通过 try/catch 保证多线程加载场景下重复注册不会中断启动。

Performance & Operational Notes

  • WatchTrace 打点是启动性能治理的核心:WatchTrace.Start() 起表,每个 Region 后 WatchTrace.Record("PlatformPrerequisite" | "CustomAppDomain.MoveFiles" | "CustomAppDomain.AssemblyLoad" | "CustomAppDomain.AssemblyResolve" | "RuntimeConfiguration")。在 STARTUP_WATCH_TRACE 或 DEBUG 构建下可精确归因"启动慢在哪一段"。
  • MoveFiles() 仅 DEBUG 执行:调试态把本机库归位到 native 目录以匹配单 RID 输出布局;Release 依赖 runtimes 目录与 DllImportResolver,不做文件搬移,避免发布版启动 IO 开销。
  • 冷启动次序设计:把"兼容性检查"放在一切重型初始化之前、把序列化/TLS 配置放在程序集解析之后,确保任一前置失败都能以最小成本退出。
  • 运维信号:-clt 无参数 → help;-clt main → 退出码 0;IPC 子进程空参数 → CommandExitCode.EmptyArrayArgs。排障时可依据这些确定性行为定位进程处于哪个分支。

Extension Points

  • public virtual Task<int> StartAsync():虚方法即主要扩展点,各平台 App 子类可在调用 base.StartAsync() 前后插入平台专属逻辑。
  • Startup(string[]? args = null) 构造注入:测试可传入自定义参数数组,绕过 Environment.GetCommandLineArgs(),使命令行协议可被单元测试覆盖。
  • URL 协议扩展:新增激活指令只需扩展 Constants.CUSTOM_URL_SCHEME 之后的参数词汇,由 Startup.Host.cs 的 switch (args[0])(见 Startup.Host.cs L274)消费,归一化层无需改动。
  • 平台文件系统接入:InitFileSystem 按平台符号分支,新平台可按 XxxFileSystem.InitFileSystem() 模式接入。

Sources

(1 files)