diff --git a/Wabbajack.CLI/Program.cs b/Wabbajack.CLI/Program.cs index 3f28c809..7bc608b8 100644 --- a/Wabbajack.CLI/Program.cs +++ b/Wabbajack.CLI/Program.cs @@ -11,6 +11,7 @@ using Octokit; using Wabbajack.CLI.TypeConverters; using Wabbajack.CLI.Verbs; using Wabbajack.DTOs.GitHub; +using Wabbajack.DTOs.Interventions; using Wabbajack.Networking.Http; using Wabbajack.Networking.Http.Interfaces; using Wabbajack.Networking.WabbajackClientApi; @@ -63,6 +64,9 @@ internal class Program services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); }).Build(); var service = host.Services.GetService(); diff --git a/Wabbajack.CLI/UserInterventionHandler.cs b/Wabbajack.CLI/UserInterventionHandler.cs new file mode 100644 index 00000000..28313f21 --- /dev/null +++ b/Wabbajack.CLI/UserInterventionHandler.cs @@ -0,0 +1,27 @@ +using System; +using Wabbajack.DTOs.Interventions; +using Wabbajack.Networking.Steam.UserInterventions; + +namespace Wabbajack.CLI; + +public class UserInterventionHandler : IUserInterventionHandler +{ + public void Raise(IUserIntervention intervention) + { + if (intervention is GetAuthCode gac) + { + switch (gac.Type) + { + case GetAuthCode.AuthType.EmailCode: + Console.WriteLine("Please enter the Steam code that was just emailed to you"); + break; + case GetAuthCode.AuthType.TwoFactorAuth: + Console.WriteLine("Please enter your 2FA code for Steam"); + break; + default: + throw new ArgumentOutOfRangeException(); + } + gac.Finish(Console.ReadLine()!.Trim()); + } + } +} \ No newline at end of file diff --git a/Wabbajack.CLI/Verbs/SteamLogin.cs b/Wabbajack.CLI/Verbs/SteamLogin.cs new file mode 100644 index 00000000..5d99c89a --- /dev/null +++ b/Wabbajack.CLI/Verbs/SteamLogin.cs @@ -0,0 +1,58 @@ +using System; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Wabbajack.Networking.Http.Interfaces; +using Wabbajack.Networking.Steam; +using Wabbajack.Paths; + +namespace Wabbajack.CLI.Verbs; + +public class SteamLogin : IVerb +{ + private readonly ILogger _logger; + private readonly Client _client; + private readonly ITokenProvider _token; + + public SteamLogin(ILogger logger, Client steamClient, ITokenProvider token) + { + _logger = logger; + _client = steamClient; + _token = token; + } + public Command MakeCommand() + { + var command = new Command("steam-login"); + command.Description = "Logs into Steam via interactive prompts"; + + command.Add(new Option(new[] {"-u", "-user"}, "Username for login")); + command.Handler = CommandHandler.Create(Run); + return command; + } + + public async Task Run(string user) + { + var token = await _token.Get(); + + if (token == null || token.User != user || string.IsNullOrWhiteSpace(token.Password)) + { + Console.WriteLine("Please enter password"); + var password = Console.ReadLine() ?? ""; + + await _token.SetToken(new SteamLoginState + { + User = user, + Password = password.Trim() + }); + } + + _logger.LogInformation("Attempting login"); + await _client.Login(); + + await Task.Delay(10000); + + return 0; + } + +} \ No newline at end of file diff --git a/Wabbajack.DTOs/Interventions/AUserIntervention.cs b/Wabbajack.DTOs/Interventions/AUserIntervention.cs new file mode 100644 index 00000000..e9a5a2b0 --- /dev/null +++ b/Wabbajack.DTOs/Interventions/AUserIntervention.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Wabbajack.DTOs.Interventions; + +public class AUserIntervention : IUserIntervention +{ + private readonly TaskCompletionSource _tcs; + private readonly CancellationTokenSource _ct; + + protected AUserIntervention() + { + _tcs = new TaskCompletionSource(); + _ct = new CancellationTokenSource(); + } + public void Cancel() + { + _ct.Cancel(); + _tcs.SetCanceled(Token); + } + + public bool Handled => _tcs.Task.IsCompleted; + public CancellationToken Token => _ct.Token; + public Task Task => _tcs.Task; + + public void Finish(T value) + { + _tcs.SetResult(value); + _ct.Cancel(); + } + + public void SetException(Exception exception) + { + _ct.Cancel(); + _tcs.SetException(exception); + } +} \ No newline at end of file diff --git a/Wabbajack.DTOs/Interventions/IUserIntervention.cs b/Wabbajack.DTOs/Interventions/IUserIntervention.cs index 8326336c..3c4bc1a0 100644 --- a/Wabbajack.DTOs/Interventions/IUserIntervention.cs +++ b/Wabbajack.DTOs/Interventions/IUserIntervention.cs @@ -3,11 +3,16 @@ using System.Threading; namespace Wabbajack.DTOs.Interventions; + +public interface IUserIntervention +{ + +} /// /// Defines a message that requires user interaction. The user must perform some action /// or make a choice. /// -public interface IUserIntervention +public interface IUserIntervention : IUserIntervention { /// /// The user didn't make a choice, so this action should be aborted diff --git a/Wabbajack.Networking.Steam.Test/ClientTests.cs b/Wabbajack.Networking.Steam.Test/ClientTests.cs new file mode 100644 index 00000000..e28b6f32 --- /dev/null +++ b/Wabbajack.Networking.Steam.Test/ClientTests.cs @@ -0,0 +1,36 @@ +using System.Threading.Tasks; +using Wabbajack.DTOs.Interventions; +using Wabbajack.Networking.Http.Interfaces; +using Xunit; + +namespace Wabbajack.Networking.Steam.Test; + +public class ClientTests +{ + private readonly ITokenProvider _token; + private readonly Client _steamClient; + private readonly IUserInterventionHandler _userInterventionHandler; + + public ClientTests(ITokenProvider token, Client client, IUserInterventionHandler userInterventionHandler) + { + _token = token; + _steamClient = client; + _userInterventionHandler = userInterventionHandler; + } + + [Fact] + public async Task CanGetLogin() + { + var token = await _token.Get(); + Assert.NotNull(token); + Assert.NotEmpty(token!.User); + } + + [Fact] + public async Task CanLogin() + { + await _steamClient.Connect(); + await _steamClient.Login(); + + } +} \ No newline at end of file diff --git a/Wabbajack.Networking.Steam.Test/Startup.cs b/Wabbajack.Networking.Steam.Test/Startup.cs new file mode 100644 index 00000000..3074aae9 --- /dev/null +++ b/Wabbajack.Networking.Steam.Test/Startup.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Wabbajack.Services.OSIntegrated; +using Xunit.DependencyInjection; +using Xunit.DependencyInjection.Logging; + +namespace Wabbajack.Networking.Steam.Test; + +public class Startup +{ + public void ConfigureServices(IServiceCollection service) + { + service.AddOSIntegrated(); + } + + public void Configure(ILoggerFactory loggerFactory, ITestOutputHelperAccessor accessor) + { + loggerFactory.AddProvider(new XunitTestOutputLoggerProvider(accessor, delegate { return true; })); + } +} \ No newline at end of file diff --git a/Wabbajack.Networking.Steam.Test/Wabbajack.Networking.Steam.Test.csproj b/Wabbajack.Networking.Steam.Test/Wabbajack.Networking.Steam.Test.csproj new file mode 100644 index 00000000..01103a7d --- /dev/null +++ b/Wabbajack.Networking.Steam.Test/Wabbajack.Networking.Steam.Test.csproj @@ -0,0 +1,28 @@ + + + + net6.0 + enable + + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/Wabbajack.Networking.Steam/Client.cs b/Wabbajack.Networking.Steam/Client.cs new file mode 100644 index 00000000..9678cafb --- /dev/null +++ b/Wabbajack.Networking.Steam/Client.cs @@ -0,0 +1,229 @@ +using System.Security; +using Microsoft.Extensions.Logging; +using SteamKit2; +using Wabbajack.DTOs.Interventions; +using Wabbajack.Networking.Http.Interfaces; +using Wabbajack.Networking.Steam.UserInterventions; + +namespace Wabbajack.Networking.Steam; + +public class Client : IDisposable +{ + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private readonly SteamClient _client; + private readonly SteamUser _steamUser; + private readonly CallbackManager _manager; + private readonly ITokenProvider _token; + private TaskCompletionSource _loginTask; + private TaskCompletionSource _connectTask; + private readonly CancellationTokenSource _cancellationSource; + + private string? _twoFactorCode; + private string? _authCode; + private readonly IUserInterventionHandler _interventionHandler; + private bool _isConnected; + private bool _isLoggedIn; + private bool _haveSigFile; + + public Client(ILogger logger, HttpClient client, ITokenProvider token, + IUserInterventionHandler interventionHandler) + { + _logger = logger; + _httpClient = client; + _interventionHandler = interventionHandler; + _client = new SteamClient(SteamConfiguration.Create(c => + { + c.WithHttpClientFactory(() => client); + c.WithProtocolTypes(ProtocolTypes.WebSocket); + c.WithUniverse(EUniverse.Public); + })); + + + _cancellationSource = new CancellationTokenSource(); + + _token = token; + + _manager = new CallbackManager(_client); + + _steamUser = _client.GetHandler()!; + + _manager.Subscribe( OnConnected ); + _manager.Subscribe( OnDisconnected ); + + _manager.Subscribe( OnLoggedOn ); + _manager.Subscribe( OnLoggedOff ); + + _manager.Subscribe( OnUpdateMachineAuthCallback ); + + _isConnected = false; + _isLoggedIn = false; + _haveSigFile = false; + + new Thread(() => + { + while (!_cancellationSource.IsCancellationRequested) + { + _manager.RunWaitCallbacks(TimeSpan.FromMilliseconds(250)); + } + }) + { + Name = "Steam Client callback runner", + IsBackground = true + } + .Start(); + } + + private void OnUpdateMachineAuthCallback(SteamUser.UpdateMachineAuthCallback callback) + { + Task.Run(async () => + { + int fileSize; + byte[] sentryHash; + + var token = await _token.Get(); + + var ms = new MemoryStream(); + + if (token?.SentryFile != null) + await ms.WriteAsync(token.SentryFile); + + ms.Seek(callback.Offset, SeekOrigin.Begin); + ms.Write(callback.Data, 0, callback.BytesToWrite); + fileSize = (int) ms.Length; + + token!.SentryFile = ms.ToArray(); + sentryHash = CryptoHelper.SHAHash(token.SentryFile); + + await _token.SetToken(token); + + + _steamUser.SendMachineAuthResponse(new SteamUser.MachineAuthDetails + { + JobID = callback.JobID, + FileName = callback.FileName, + + BytesWritten = callback.BytesToWrite, + FileSize = token.SentryFile.Length, + Offset = callback.Offset, + Result = EResult.OK, + LastError = 0, + OneTimePassword = callback.OneTimePassword, + SentryFileHash = sentryHash + }); + + _haveSigFile = true; + _loginTask.TrySetResult(); + }); + } + + private void OnLoggedOff(SteamUser.LoggedOffCallback obj) + { + _isLoggedIn = false; + } + + private void OnLoggedOn(SteamUser.LoggedOnCallback callback) + { + Task.Run(async () => + { + var isSteamGuard = callback.Result == EResult.AccountLogonDenied; + var is2FA = callback.Result == EResult.AccountLoginDeniedNeedTwoFactor; + + if (isSteamGuard || is2FA) + { + _logger.LogInformation("Account is SteamGuard protected"); + if (is2FA) + { + var intervention = new GetAuthCode(GetAuthCode.AuthType.TwoFactorAuth); + _interventionHandler.Raise(intervention); + _twoFactorCode = await intervention.Task; + } + else + { + var intervention = new GetAuthCode(GetAuthCode.AuthType.EmailCode); + _interventionHandler.Raise(intervention); + _authCode = await intervention.Task; + } + + var tcs = Login(_loginTask); + return; + } + + if (callback.Result != EResult.OK) + { + _loginTask.SetException(new SteamException("Unable to log in", callback.Result, callback.ExtendedResult)); + return; + } + + _isLoggedIn = true; + _logger.LogInformation("Logged into Steam"); + if (_haveSigFile) + _loginTask.SetResult(); + }); + } + + private void OnDisconnected(SteamClient.DisconnectedCallback obj) + { + _isConnected = false; + _logger.LogInformation("Logged out"); + } + + private void OnConnected(SteamClient.ConnectedCallback obj) + { + Task.Run(async () => + { + var state = (await _token.Get())!; + _logger.LogInformation("Connected to Steam, logging in as {User}", state.User); + + byte[]? sentryHash = null; + + + if (state.SentryFile != null) + { + _logger.LogInformation("Existing login keys found, reusing"); + sentryHash = CryptoHelper.SHAHash(state.SentryFile); + _haveSigFile = true; + } + else + { + _haveSigFile = false; + } + + + _isConnected = true; + + _steamUser.LogOn(new SteamUser.LogOnDetails() + { + Username = state.User, + Password = state.Password, + AuthCode = _authCode, + TwoFactorCode = _twoFactorCode, + SentryFileHash = sentryHash + }); + }); + } + + public Task Connect() + { + _connectTask = new TaskCompletionSource(); + + _client.Connect(); + return _connectTask.Task; + } + + public void Dispose() + { + _httpClient.Dispose(); + _cancellationSource.Cancel(); + _cancellationSource.Dispose(); + } + + public Task Login(TaskCompletionSource? tcs = null) + { + _loginTask = tcs ?? new TaskCompletionSource(); + _logger.LogInformation("Attempting login"); + _client.Connect(); + + return _loginTask.Task; + } +} \ No newline at end of file diff --git a/Wabbajack.Networking.Steam/SteamException.cs b/Wabbajack.Networking.Steam/SteamException.cs new file mode 100644 index 00000000..58ee9ae3 --- /dev/null +++ b/Wabbajack.Networking.Steam/SteamException.cs @@ -0,0 +1,16 @@ +using SteamKit2; + +namespace Wabbajack.Networking.Steam; + +public class SteamException : Exception +{ + public EResult Result { get; } + public EResult ExtendedResult { get; } + + public SteamException(string message, EResult result, EResult extendedResult) : base($"{message} {result} / {extendedResult}") + { + Result = result; + ExtendedResult = extendedResult; + } + +} \ No newline at end of file diff --git a/Wabbajack.Networking.Steam/SteamLoginState.cs b/Wabbajack.Networking.Steam/SteamLoginState.cs new file mode 100644 index 00000000..fb2ee469 --- /dev/null +++ b/Wabbajack.Networking.Steam/SteamLoginState.cs @@ -0,0 +1,8 @@ +namespace Wabbajack.Networking.Steam; + +public class SteamLoginState +{ + public byte[]? SentryFile { get; set; } + public string User { get; set; } + public string Password { get; set; } +} \ No newline at end of file diff --git a/Wabbajack.Networking.Steam/UserInterventions/GetAuthCode.cs b/Wabbajack.Networking.Steam/UserInterventions/GetAuthCode.cs new file mode 100644 index 00000000..07254d53 --- /dev/null +++ b/Wabbajack.Networking.Steam/UserInterventions/GetAuthCode.cs @@ -0,0 +1,14 @@ +using Wabbajack.DTOs.Interventions; + +namespace Wabbajack.Networking.Steam.UserInterventions; + +public class GetAuthCode : AUserIntervention +{ + public enum AuthType + { + TwoFactorAuth, + EmailCode + } + public GetAuthCode(AuthType type) => Type = type; + public AuthType Type { get; } +} \ No newline at end of file diff --git a/Wabbajack.Networking.Steam/UserInterventions/GetUsernameAndPassword.cs b/Wabbajack.Networking.Steam/UserInterventions/GetUsernameAndPassword.cs new file mode 100644 index 00000000..3f57de4c --- /dev/null +++ b/Wabbajack.Networking.Steam/UserInterventions/GetUsernameAndPassword.cs @@ -0,0 +1,19 @@ +using Wabbajack.DTOs.Interventions; + +namespace Wabbajack.Networking.Steam.UserInterventions; + +public class GetUsernameAndPassword : IUserIntervention +{ + public void Cancel() + { + throw new NotImplementedException(); + } + + public bool Handled { get; } + public CancellationToken Token { get; } + + public void SetException(Exception exception) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Wabbajack.Networking.Steam/Wabbajack.Networking.Steam.csproj b/Wabbajack.Networking.Steam/Wabbajack.Networking.Steam.csproj new file mode 100644 index 00000000..bdfe45cc --- /dev/null +++ b/Wabbajack.Networking.Steam/Wabbajack.Networking.Steam.csproj @@ -0,0 +1,19 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + + + diff --git a/Wabbajack.Services.OSIntegrated/LoggingRateLimiterReporter.cs b/Wabbajack.Services.OSIntegrated/LoggingRateLimiterReporter.cs index 2767bda1..544385f9 100644 --- a/Wabbajack.Services.OSIntegrated/LoggingRateLimiterReporter.cs +++ b/Wabbajack.Services.OSIntegrated/LoggingRateLimiterReporter.cs @@ -41,13 +41,21 @@ public class LoggingRateLimiterReporter : IDisposable var report = NextReport(); var sb = new StringBuilder(); sb.Append($"[#{_reportNumber}] "); + + var found = false; foreach (var (prev, next, limiter) in _prevReport.Zip(report, _limiters)) { var throughput = next.Transferred - prev.Transferred; - sb.Append($"{limiter.Name}: [{next.Running}/{next.Pending}] {throughput.ToFileSizeString()}/sec "); + if (throughput > 0) + { + found = true; + sb.Append($"{limiter.Name}: [{next.Running}/{next.Pending}] {throughput.ToFileSizeString()}/sec "); + } } - _logger.LogInformation(sb.ToString()); + if (found) + _logger.LogInformation(sb.ToString()); + _prevReport = report; } } \ No newline at end of file diff --git a/Wabbajack.Services.OSIntegrated/ServiceExtensions.cs b/Wabbajack.Services.OSIntegrated/ServiceExtensions.cs index c7a50f77..de34db56 100644 --- a/Wabbajack.Services.OSIntegrated/ServiceExtensions.cs +++ b/Wabbajack.Services.OSIntegrated/ServiceExtensions.cs @@ -15,13 +15,14 @@ using Wabbajack.Networking.Discord; using Wabbajack.Networking.Http; using Wabbajack.Networking.Http.Interfaces; using Wabbajack.Networking.NexusApi; -using Wabbajack.Networking.WabbajackClientApi; +using Wabbajack.Networking.Steam; using Wabbajack.Paths; using Wabbajack.Paths.IO; using Wabbajack.RateLimiter; using Wabbajack.Services.OSIntegrated.Services; using Wabbajack.Services.OSIntegrated.TokenProviders; using Wabbajack.VFS; +using Client = Wabbajack.Networking.WabbajackClientApi.Client; namespace Wabbajack.Services.OSIntegrated; @@ -106,6 +107,8 @@ public static class ServiceExtensions service.AddSingleton(); service.AddAllSingleton(); + service.AddSingleton(); + service.AddSingleton(); service.AddSingleton(); @@ -118,6 +121,10 @@ public static class ServiceExtensions .AddAllSingleton, EncryptedJsonTokenProvider, VectorPlexusTokenProvider>(); + service + .AddAllSingleton, EncryptedJsonTokenProvider, + SteamTokenProvider>(); + service.AddAllSingleton, WabbajackApiTokenProvider>(); service diff --git a/Wabbajack.Services.OSIntegrated/TokenProviders/SteamTokenProvider.cs b/Wabbajack.Services.OSIntegrated/TokenProviders/SteamTokenProvider.cs new file mode 100644 index 00000000..1431fee3 --- /dev/null +++ b/Wabbajack.Services.OSIntegrated/TokenProviders/SteamTokenProvider.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.Logging; +using Wabbajack.DTOs.JsonConverters; +using Wabbajack.Networking.Steam; + +namespace Wabbajack.Services.OSIntegrated.TokenProviders; + +public class SteamTokenProvider : EncryptedJsonTokenProvider +{ + public SteamTokenProvider(ILogger logger, DTOSerializer dtos) : base(logger, dtos, + "steam-login") + { + } +} \ No newline at end of file diff --git a/Wabbajack.Services.OSIntegrated/Wabbajack.Services.OSIntegrated.csproj b/Wabbajack.Services.OSIntegrated/Wabbajack.Services.OSIntegrated.csproj index 685b6152..71a1455f 100644 --- a/Wabbajack.Services.OSIntegrated/Wabbajack.Services.OSIntegrated.csproj +++ b/Wabbajack.Services.OSIntegrated/Wabbajack.Services.OSIntegrated.csproj @@ -21,6 +21,7 @@ + diff --git a/Wabbajack.sln b/Wabbajack.sln index 6d9084e1..9393b704 100644 --- a/Wabbajack.sln +++ b/Wabbajack.sln @@ -116,6 +116,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wabbajack.Launcher", "Wabba EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wabbajack.App.Wpf", "Wabbajack.App.Wpf\Wabbajack.App.Wpf.csproj", "{372B2DD2-EAA3-4E18-98A7-B9838C7B41F4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wabbajack.Networking.Steam", "Wabbajack.Networking.Steam\Wabbajack.Networking.Steam.csproj", "{AB9A5C22-10CC-4EE0-A808-FB1DC9E24247}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wabbajack.Networking.Steam.Test", "Wabbajack.Networking.Steam.Test\Wabbajack.Networking.Steam.Test.csproj", "{D6351587-CAF6-4CB6-A2BD-5368E69F297C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -322,6 +326,14 @@ Global {372B2DD2-EAA3-4E18-98A7-B9838C7B41F4}.Debug|Any CPU.Build.0 = Debug|x64 {372B2DD2-EAA3-4E18-98A7-B9838C7B41F4}.Release|Any CPU.ActiveCfg = Release|x64 {372B2DD2-EAA3-4E18-98A7-B9838C7B41F4}.Release|Any CPU.Build.0 = Release|x64 + {AB9A5C22-10CC-4EE0-A808-FB1DC9E24247}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB9A5C22-10CC-4EE0-A808-FB1DC9E24247}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB9A5C22-10CC-4EE0-A808-FB1DC9E24247}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB9A5C22-10CC-4EE0-A808-FB1DC9E24247}.Release|Any CPU.Build.0 = Release|Any CPU + {D6351587-CAF6-4CB6-A2BD-5368E69F297C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D6351587-CAF6-4CB6-A2BD-5368E69F297C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D6351587-CAF6-4CB6-A2BD-5368E69F297C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D6351587-CAF6-4CB6-A2BD-5368E69F297C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {4057B668-8595-44FE-9805-007B284A838F} = {98B731EE-4FC0-4482-A069-BCBA25497871} @@ -358,5 +370,7 @@ Global {29AC8A68-D5EC-43F5-B2CC-72A75545E418} = {98B731EE-4FC0-4482-A069-BCBA25497871} {DEB4B073-4EAA-49FD-9D43-F0F8CB930E7A} = {F01F8595-5FD7-4506-8469-F4A5522DACC1} {4F252332-CA77-41DE-95A8-9DF38A81D675} = {98B731EE-4FC0-4482-A069-BCBA25497871} + {AB9A5C22-10CC-4EE0-A808-FB1DC9E24247} = {F01F8595-5FD7-4506-8469-F4A5522DACC1} + {D6351587-CAF6-4CB6-A2BD-5368E69F297C} = {F01F8595-5FD7-4506-8469-F4A5522DACC1} EndGlobalSection EndGlobal