Repository Wiki
BeyondDimension/SteamTools

应用自动更新子系统

应用自动更新子系统负责客户端的版本检查、新版本提示、更新包下载与校验、解压以及向平台层(IPC)移交安装信息,是 Watt Toolkit(SteamTools)跨平台客户端实现"不重装即升级"的核心机制。其契约由 IAppUpdateService 定义,跨平台核心逻辑集中在抽象基类 AppUpdateServiceBaseImpl,平台差异(如 Avalonia 桌面端)由子类与平台服务(IPC)承接。

目的与范围

本页覆盖该子系统的完整端到端链路:

  • 服务契约:IAppUpdateService 接口暴露的状态、命令与静态工具方法(升级包命名、缓存目录管理)。
  • 核心实现:AppUpdateServiceBaseImpl 中的版本检查、新版本提示、下载、哈希校验、解压覆盖与 IPC 移交逻辑。
  • 平台扩展:AvaloniaApplicationUpdateServiceImpl 子类与 ServiceCollectionExtensions.AddApplicationUpdateService 的 DI 注册入口。
  • 进度与通知:OnReport 系列进度上报与系统通知下载进度条的联动。
  • 失败模式:AppUpdateFailCode 错误码、哈希校验失败重下、解压失败回退、App Store / 官网兜底。

以下内容不在本页范围内,属于兄弟页面:

  • 服务端版本发布 API 与 AppVersionDTO 的完整模型定义(属于服务端接口契约页)。
  • IMicroServiceClient HTTP 客户端与全局错误处理的内部实现(属于网络服务页)。
  • IPC 框架本身与升级包在重启后的落盘细节(属于 IPC / 平台服务页,入口见 IPCPlatformService.AppUpdate.cs)。
  • 通知服务 INotificationService 的内部实现(属于通知子系统页)。

概述

自动更新子系统解决了跨平台客户端的分发差异问题:Windows/Linux 桌面端与 Android 端可以由服务端直接分发更新包(全量 tar.gz / 7z / apk),而 iOS、UWP 打包等场景必须走应用商店。为此,服务通过 IsSupportedServerDistribution 属性在运行时判定是否允许"下载并覆盖升级",不支持时自动降级为"打开应用商店 / 官网"。

核心使用场景:

  1. 启动/手动检查更新:调用 CheckUpdateAsync,将平台、设备形态、系统版本、CPU 架构、部署模式上报给服务端 client.Version.CheckUpdate,由服务端返回匹配的 AppVersionDTO。
  2. 新版本提示:发现新版本后,根据是否有活动窗口选择弹窗(MessageBox)或系统通知,用户确认后触发 StartUpdateCommand。
  3. 下载与安装:DownloadUpdate 按渠道选择压缩包 → 下载到缓存目录 → SHA256 校验 → 解压 → OverwriteUpgradePrivate 计算目录 SHA384 并通过 IPlatformService.IPCRoot 的 SetUpdatePackageInfo(path, sha384Data) 移交给平台层执行覆盖安装(重启时生效)。
  4. 兜底降级:DisableAutomateUpdate 的版本、无法直连分发的平台,统一 OpenInAppStore 跳转商店或官网。

架构

Loading diagram...

架构分层说明:

  • 契约层 IAppUpdateService:除常规属性与方法外,还承担静态职责——定义升级包目录常量 PackDirName = "UpgradePackages"、生成升级包文件名 GetPackName、管理缓存目录 GetPackCacheDirPath / ClearAllPackCacheDir,并持有跨进程传递的 UpdatePackageInfo (Path, SHA384) 元组(internal set,仅供程序集内写入)。
  • 抽象实现 AppUpdateServiceBaseImpl:继承 ReactiveObject,集中了全部与平台无关的状态机(IsExistUpdate、NewVersionInfo、进度三元组)与流程(检查 → 提示 → 下载 → 校验 → 解压 → 移交)。它通过构造函数注入 IApplication、INotificationService、IToast、IMicroServiceClient、IOptions<AppSettings> 五个依赖。
  • 平台子类 AvaloniaApplicationUpdateServiceImpl:sealed class AvaloniaApplicationUpdateServiceImpl : AppUpdateServiceBaseImpl,只补齐 Avalonia 桌面端的平台细节(如 HasActiveWindow 的窗口判断),注册入口位于 ServiceCollectionExtensions.AddApplicationUpdateService.cs。
  • 移交层 IPC:基类不直接改写自身文件,而是把"解压后的目录 + SHA384"交给 IPlatformService.IPCRoot.Instance 返回的平台服务(IPCPlatformService.AppUpdate.cs 实现该接口面),由平台层在合适的进程/时机完成覆盖安装——这是"客户端进程不能覆盖自身运行文件"这一约束下的必然设计。

核心流程

更新生命周期由两条主要路径组成:版本检查路径与下载安装路径。二者通过 StartUpdateCommand 衔接。

Loading diagram...

流程要点与设计意图:

  • 两级防重入:static bool isCheckUpdateing 与 static bool isDownloading 分别保护检查与下载阶段,防止用户连点或定时任务并发触发重复请求/重复下载。
  • 非强制检查的缓存短路:force == false 且内存中已有 NewVersionInfo 时直接复用结果重新弹出提示,不再请求 API;若 IsExistUpdate 为 true 但 NewVersionInfo 为空(异常状态),则重置 IsExistUpdate = false 并继续走网络检查,属于自愈逻辑。
  • 版本比较的防御性实现:IsNewVersion 局部函数先用字符串等值判断,再 Version.TryParse 双方版本号;只要任一侧解析失败就退化为"字符串不等即视为新版本",只有解析成功且 newVersion <= thisVersion 才判定为无更新。这保证服务端返回畸形版本号时不会静默吞掉更新。
  • 缓存命中即跳过下载:下载前先检查 GetPackCacheDirPath 下是否已有同名升级包(文件名含版本号 + Id 的 Crc32,天然区分版本),命中且 SHA256 通过则直接进入覆盖安装,节省重复下载带宽。

实现细节

1. 版本检查与设备指纹上报

CheckUpdateAsync 采集的五个维度直接决定服务端返回哪个包:

csharp
1var platform = DeviceInfo2.Platform(); 2var deviceIdiom = DeviceInfo2.Idiom(); 3if (deviceIdiom == DeviceIdiom.Tablet && platform == Platform.Apple) 4{ 5 deviceIdiom = DeviceIdiom.Phone; 6} 7var osVersion = OSVersion; 8var architecture = OperatingSystem2.IsWindows() ? 9 RuntimeInformation.ProcessArchitecture : 10 RuntimeInformation.OSArchitecture; 11var deploymentMode = application.DeploymentMode; 12var rsp = await client.Version.CheckUpdate( 13 platform, deviceIdiom, 14 osVersion, 15 architecture, 16 deploymentMode);

Source: AppUpdateServiceBaseImpl.cs

  • iPad 归一化为 Phone:Apple 平台上 Tablet 设备归并到 Phone 形态,减少服务端需要维护的形态矩阵。
  • 架构取值口径差异:Windows 上取 ProcessArchitecture(因为 Windows 应用可为 x86/x64 进程跑在不同架构系统上),其他平台取 OSArchitecture。
  • OSVersion 是虚属性:基类返回 Environment.OSVersion.Version,子类可重写以适配平台差异。
  • IsSupportedServerDistribution 的平台开关:Windows 下若以 DesktopBridge(UWP 打包)运行则返回 false,其余 Windows 场景返回 true;非 Windows 编译目标(该常量由条件编译 #if WINDOWS 控制)返回 false,即 iOS 等只能走商店分发。

2. 新版本提示策略(有窗/无窗双路径)

csharp
1protected virtual async void OnExistNewVersion() 2{ 3 var hasActiveWindow = HasActiveWindow(); 4 if (hasActiveWindow) 5 { 6 await ShowNewVersionWindowAsync(); 7 } 8 else 9 { 10 ShowNewVersionWindowOnMainOpen = true; 11 ShowNewVersionNotifyAsync(); 12 } 13}

Source: AppUpdateServiceBaseImpl.cs

设计意图:后台检查到新版本时用户可能正在别的窗口工作,强弹窗体验极差;因此无活动窗口时仅发系统通知并置 ShowNewVersionWindowOnMainOpen = true,等 OnMainOpenTryShowNewVersionWindow()(主窗口打开钩子)时再补一次弹窗。HasActiveWindow() 是抽象方法,由各平台子类按窗口管理器实现。发布说明 NewVersionInfoDesc 将服务端以 ; 分隔的 ReleaseNote 按行重排展示。

3. 下载渠道选择与请求 URL 组装

csharp
1var isAndroid = OperatingSystem.IsAndroid(); 2var isDesktop = IApplication.IsDesktop(); 3... 4if (isAndroid) 5{ 6 CloudFileType apk = (CloudFileType)265; 7 download = GetByDownloadChannelSettings( 8 newVersionInfo.Downloads.Where(x => x.DownloadType == apk)); 9} 10else if (isDesktop) 11{ 12 download = GetByDownloadChannelSettings( 13 newVersionInfo.Downloads.Where(x => x.DownloadType == CloudFileType.SevenZip)); 14 download ??= GetByDownloadChannelSettings( 15 newVersionInfo.Downloads.Where(x => x.DownloadType == CloudFileType.TarGzip)); 16}

Source: AppUpdateServiceBaseImpl.cs

桌面端优先 7z(压缩率更高、体积更小),缺失时回退 tar.gz;Android 取 DownloadType == 265(apk 类型)。最终下载地址有三条组装路径:DownloadUrl 为空时按官方站点发布文件名规则拼接({filePlatform}-{fileArch}_{version}{fileEx});为完整 HTTP URL 时直用;否则视为文件 Id 走 OfficialWebsite_UploadsPublishFiles 单文件接口。

4. 缓存复用、SHA256 校验与断点续传开关

csharp
1var packFileName = GetPackName(newVersionInfo, isDirOrFile: true); 2var packFilePath = Path.Combine(GetPackCacheDirPath(!isSupportedResume), packFileName); 3if (File.Exists(packFilePath)) // 存在压缩包文件 4{ 5 if (string.IsNullOrWhiteSpace(download.SHA256) || UpdatePackVerification(packFilePath, download.SHA256)) 6 { 7 isCallOverwriteUpgrade = true; 8 OverwriteUpgrade(packFilePath, isIncrement: false, downloadType: download.DownloadType); 9 goto end; 10 } 11 else // 验证文件失败,删除源文件,将会重新下载 12 { 13 ... 14 Fail(FailCode.UpdatePackCacheHashInvalidDeleteFileFail_, packFilePath); 15 ... 16 } 17}

Source: AppUpdateServiceBaseImpl.cs

  • const bool isSupportedResume = false:当前不支持断点续传,因此每次进入下载流程会 GetPackCacheDirPath(clear: true) 清理所有 .download_cache 临时文件。
  • UpdatePackVerification 以 File.OpenRead 流式计算 SHA256 并带进度回报(OnReportCalcHashing),与下载进度共用同一套进度体系;FileNotFoundException 被捕获并返回 false 而非抛出,让上层走"删除重下"路径。
  • 服务端未下发 SHA256(空字符串)时跳过校验,保持兼容旧版本记录。

5. 解压覆盖与 IPC 移交(安装的关键一步)

csharp
1var isOK = await Task.Run(() => 2{ 3 try 4 { 5 return downloadType switch 6 { 7 CloudFileType.TarGzip 8 => TarGZipHelper.Unpack(value, dirPath, 9 progress: new Progress<float>(OnReportDecompressing), 10 maxProgress: 100f), 11 CloudFileType.SevenZip 12 => ISevenZipHelper.Instance.Unpack(value, dirPath, 13 progress: new Progress<float>(OnReportDecompressing), 14 maxProgress: 100f), 15 _ => false, 16 }; 17 } 18 catch (Exception ex) 19 { 20 Log.Error(TAG, ex, "Compressed unpack catch."); 21 return false; 22 } 23}); 24if (isOK) 25{ 26 OnReport(100f); 27 IOPath.FileTryDelete(value); 28 OverwriteUpgradePrivate(dirPath); 29} 30 31static async void OverwriteUpgradePrivate(string dirPath) 32{ 33 var path = dirPath.TrimEnd(Path.DirectorySeparatorChar); 34 35 byte[]? sha384Data = null; 36 using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) 37 { 38 sha384Data = SHA384.HashData(stream); 39 } 40 41 var platformService = await IPlatformService.IPCRoot.Instance; 42 platformService.SetUpdatePackageInfo(path, sha384Data); 43}

Source: AppUpdateServiceBaseImpl.cs

  • 解压目标目录是 IOPath.BaseDirectory 下与压缩包同名的目录;解压前若已存在则 Directory.Delete(dirPath, true) 清理旧残留。
  • 解压在 Task.Run 中执行并整体 try/catch,任何压缩库异常都转为 isOK = false,上层仅提示 UpdateUnpackFail 并恢复 IsNotStartUpdateing,不会让异常逃逸到 UI 线程。
  • OverwriteUpgradePrivate 打开目录流时使用 FileShare.ReadWrite | FileShare.Delete,避免与仍在写文件的解压线程冲突;对目录计算 SHA384.HashData 后调用 IPlatformService.IPCRoot 拿到平台服务实例并 SetUpdatePackageInfo(path, sha384Data),把安装责任移交给平台/宿主进程——这是防止客户端覆盖自身正在运行文件的标准做法。接口侧对应 IAppUpdateService.UpdatePackageInfo(static ... internal set),IPC 面由 IPCPlatformService.AppUpdate.cs 暴露。
  • 增量更新分支 isIncrement == true 直接把缓存文件夹路径交给 OverwriteUpgradePrivate,跳过解压(OverwriteUpgrade 的 XML 注释明确了两种语义)。

6. 进度上报与系统通知下载条

csharp
1protected virtual void OnReport(float value, string str) 2{ 3 ProgressValue = value; 4 ProgressString = str; 5 6 if (notification.IsSupportNotifyDownload) 7 { 8 if (value == 0) 9 { 10 progress?.Report(100f); 11 progress = notification.NotifyDownload(() => ProgressString, 12 NotificationType.NewVersion); 13 } 14 else if (value == 100f) 15 { 16 progress?.Report(100f); 17 progress = null; 18 } 19 else 20 { 21 progress?.Report(value); 22 } 23 } 24}

Source: AppUpdateServiceBaseImpl.cs

OnReport 是整个子系统的进度总线:OnReportDownloading / OnReportCalcHashing / OnReportDecompressing(及带 current/count 的多文件变体)都以百分比 0–100 驱动它。value == 0 时通过 INotificationService.NotifyDownload 创建常驻通知下载条(Func<string> 惰性读取最新 ProgressString),value == 100f 时上报满值并置空引用收尾。进度状态同时写入 ProgressValue / ProgressString 两个响应式属性供 UI 绑定。

数据与持久化

子系统本身不维护数据库;持久化面由三部分构成:

数据位置说明
升级包缓存IOPath.CacheDirectory/UpgradePackages(PackDirName 常量)下载完成的压缩包与 .download_cache 临时文件
解压产物IOPath.BaseDirectory/{packFileName}(去扩展名目录)覆盖安装的文件来源,安装前被整体替换
安装移交信息IAppUpdateService.UpdatePackageInfo (string Path, byte[] SHA384)静态元组,internal set,经 SetUpdatePackageInfo 写入,供平台层消费

升级包命名规则由契约层的静态方法给出,版本号 + Id 的 CRC32 保证不同发布记录不冲突:

csharp
protected static string GetPackName(AppVersionDTO m, bool isDirOrFile) => $"{m.Version}@{Hashs.String.Crc32(m.Id.ToByteArray())}{(isDirOrFile ? "" : $"{FileEx.TAR_GZ}")}";

Source: IAppUpdateService.cs

缓存目录管理提供两个重载:GetPackCacheDirPath(onNotExistsCreateDirectory, out exists) 负责取目录并按需创建;GetPackCacheDirPath(clear: true) 在清理模式下删除目录内全部 *.download_cache 文件(IOPath.FileTryDelete 容错删除),另有 ClearAllPackCacheDir() 用于彻底清空缓存目录。

使用示例

基本用法:检查更新并触发下载

CheckUpdate 是接口提供的 async void 便捷包装,适合 UI 事件直接挂接:

csharp
1async void CheckUpdate(bool force = false, bool showIsExistUpdateFalse = true) 2{ 3 await CheckUpdateAsync(force, showIsExistUpdateFalse); 4}

Source: IAppUpdateService.cs

单例访问通过 IAppUpdateService.Instance => Ioc.Get<IAppUpdateService>() 完成;桌面端实例由 ServiceCollectionExtensions.AddApplicationUpdateService 注册(位于 src/BD.WTTS.Client.Avalonia/Extensions/ServiceCollection/ServiceCollectionExtensions.AddApplicationUpdateService.cs),将 AvaloniaApplicationUpdateServiceImpl 绑定到 IAppUpdateService。

进阶用法:用户确认后启动更新

新版本弹窗的确认按钮直接触发命令式更新入口,StartUpdate 内部完成分发能力判定:

csharp
1public async virtual Task ShowNewVersionWindowAsync() 2{ 3 await MainThread2.InvokeOnMainThreadAsync(async () => 4 { 5 var result = await MessageBox.ShowAsync(NewVersionInfoDesc, 6 NewVersionInfoTitle, 7 MessageBox.Button.OKCancel); 8 if (result.IsOK()) 9 { 10 StartUpdateCommand.Invoke(); 11 } 12 }); 13}

Source: AppUpdateServiceBaseImpl.cs

csharp
1void StartUpdate() 2{ 3 if (IsSupportedServerDistribution && 4 NewVersionInfo.HasValue() && 5 !NewVersionInfo!.DisableAutomateUpdate) 6 { 7 IsNotStartUpdateing = false; 8 DownloadUpdate(); 9 } 10 else 11 { 12 OpenInAppStore(); 13 } 14}

Source: AppUpdateServiceBaseImpl.cs

注意 StartUpdate 在 #if DEBUG 下改为 public,便于调试时手动驱动;OpenInAppStore 在 Windows UWP 打包下打开 Microsoft Store 协议链接,否则打开官网。

API 参考

IAppUpdateService(契约接口)

成员签名说明
Instancestatic IAppUpdateServiceIoc.Get<IAppUpdateService>() 单例入口
UpdatePackageInfostatic (string Path, byte[] SHA384)更新包路径与 SHA384,internal set
PackDirNameconst string值 "UpgradePackages",升级包缓存目录名
IsNotStartUpdateingbool { get; }未处于更新流程中(UI 可绑定控制按钮态)
ProgressValue / ProgressStringfloat / string进度值与本地化进度文案
IsSupportedServerDistributionbool { get; }是否支持服务端直发;false 时不可用下载覆盖升级(iOS 必须 App Store)
IsExistUpdatebool { get; }是否存在更新
NewVersionInfoAppVersionDTO?新版本信息(ReleaseNote、Downloads 等)
NewVersionInfoDesc / NewVersionInfoTitlestring发布说明(按 ; 分行)与标题格式化结果
CheckUpdateasync void (force, showIsExistUpdateFalse)CheckUpdateAsync 的 fire-and-forget 包装
CheckUpdateAsyncTask (force, showIsExistUpdateFalse)检查更新;force=false 时命中内存缓存则跳过 API
StartUpdateCommandICommand启动更新(下载或跳商店)
GetPackNamestatic string (AppVersionDTO, bool isDirOrFile)生成升级包名 {Version}@{Crc32(Id)}{ext?}
GetPackCacheDirPathstatic string (bool, out bool) / (bool clear)取/建/清缓存目录
ClearAllPackCacheDirstatic void ()清空缓存目录

AppUpdateServiceBaseImpl(关键受保护/公共成员)

成员签名说明
CheckUpdateAsyncTask (bool force, bool showIsExistUpdateFalse)全量检查流程,见核心流程一节
DownloadUpdateprotected async void ()下载 + 校验 + 解压 + 移交;IsSupportedServerDistribution 为 false 时抛 PlatformNotSupportedException
OverwriteUpgradeprotected virtual async void (string value, bool isIncrement, CloudFileType downloadType = default)isIncrement=false 为压缩包全量更新,true 为缓存目录增量更新
OpenInAppStoreprotected virtual async void ()UWP → Store 协议链接,其余 → 官网
OnExistNewVersionprotected virtual async void ()有窗弹窗 / 无窗通知 + 延迟弹窗
OnReportprotected virtual void (float value, string str)进度总线,联动通知下载条
UpdatePackVerificationbool (string filePath, string sha256, int current = 0, int count = 0)流式 SHA256 校验,失败/文件缺失返回 false
HasActiveWindowprotected abstract bool ()平台子类必须实现
UpdateChannelTypestatic UpdateChannelType([Obsolete])读取 GeneralSettings.UpdateChannel.Value,历史渠道选择逻辑已注释停用

异常与失败行为:DownloadUpdate 在不支持平台抛 PlatformNotSupportedException;无匹配 download 记录或未知的压缩类型走 FailCode.UpdateEnumOutOfRange;下载失败 FailCode.DownloadUpdateFail;下载后校验失败 FailCode.UpdatePackVerificationFail;缓存文件校验失败且删除失败 FailCode.UpdatePackCacheHashInvalidDeleteFileFail_。Fail 本地函数统一:toast 展示错误文案 + Browser2.Open 跳转带错误码((byte)failCode)的官网说明页 + 进度置 100 收尾。API 层失败不额外处理("ServerApiClient 错误已由全局处理")。

失败模式、边界与并发

  • 并发防重入:isCheckUpdateing / isDownloading 两个静态布尔在方法首尾配对置位/复位;下载流程统一从 end: 标签处复位并依据 isCallOverwriteUpgrade 决定是否恢复 IsNotStartUpdateing(已移交安装时不恢复,避免 UI 再次可点打断安装)。
  • 断点续传未启用:isSupportedResume = false,下载前会清理 .download_cache,大包失败即全量重来;client.Download 以 isAnonymous: true 匿名下载,下载中进度实时写入 .download_cache 后缀文件,成功后 File.Move 改名为正式包名。
  • 缓存损坏自愈:命中缓存但 SHA256 不符 → 删除缓存文件重下;删除本身抛异常(文件被占用等)→ Fail(UpdatePackCacheHashInvalidDeleteFileFail_) 并中止。
  • 枚举越界防护:Platform / SupportedAbis / DownloadType 出现未预期值即 Fail(FailCode.UpdateEnumOutOfRange) 提前结束,fileArch 的 default 分支直接以整型值拼文件名兜底。
  • 异步 void 的取舍:DownloadUpdate / OverwriteUpgrade / OnExistNewVersion 均为 async void,内部以 try/catch 或 Fail 收口,保证异常不会击穿 UI;代价是调用方无法 await 完成状态,UI 状态依赖 IsNotStartUpdateing 响应式属性。
  • DEBUG 与发布差异:NewVersionInfo 的 setter 在 DEBUG 下为 public(便于测试注入),StartUpdate 同理;发布版本两者均收紧为 protected/非 public。

性能与运维要点

  • 下载与解压均为带进度回调的流式实现,Progress<float> 回调封送到 UI 线程更新绑定属性,长耗时压缩解压置于 Task.Run 避免阻塞。
  • 哈希计算分两级:下载完成后的包级 SHA256(可配置,服务端为空则跳过)与移交 IPC 前的目录级 SHA384(恒执行),后者是平台层验证安装完整性的依据。
  • 更新渠道设置 UpdateChannelType 及 GetByDownloadChannelSettings 均已标记 [Obsolete],代码注释指出正确方向是"将首选渠道传入 API 请求,返回中取首个"——即渠道过滤应下沉到服务端。
  • 运维兜底路径:所有失败码均可通过 OfficialWebsite_AppUpdateFailCode_ 模板跳转到官网对应说明页,供用户自助排查。

扩展点

  • 新增平台支持:继承 AppUpdateServiceBaseImpl,实现 HasActiveWindow(),并按需重写 OSVersion、IsSupportedServerDistribution、ShowNewVersionWindowAsync 等虚成员;再通过各平台的 ServiceCollectionExtensions.AddApplicationUpdateService 注册到 DI。Avalonia 桌面端参考 AvaloniaApplicationUpdateServiceImpl : AppUpdateServiceBaseImpl。
  • 新增压缩格式:在 OverwriteUpgrade 的 downloadType switch 中增加 CloudFileType 分支并接入对应解压帮助类即可,解压成功后统一走 OverwriteUpgradePrivate 移交。
  • 启用断点续传:将 isSupportedResume 置 true 并在下载侧实现 Range 续传与 .download_cache 合并逻辑(当前清理逻辑会与之冲突,需一并调整)。
  • 替换提示 UI:重写 ShowNewVersionWindowAsync / ShowNewVersionNotifyAsync / OnExistNewVersion 可完全定制新版本呈现方式。

相关链接

Sources

(2 files)
src/BD.WTTS.Client/Services.Implementation/App
src/BD.WTTS.Client/Services/App