Add LegalChecker class
MODIFY
WalletWasabi.Fluent/ViewModels/AddWallet/AddWalletPageViewModel.cs
WalletWasabi.Fluent/ViewModels/AddWallet/AddWalletPageViewModel.cs
@@ -39,7 +39,6 @@ namespace WalletWasabi.Fluent.ViewModels.AddWallet
[AutoNotify] private bool _enableCancel;
public AddWalletPageViewModel(
- LegalDocuments legalDocuments,
WalletManager walletManager,
BitcoinStore store,
Network network)

MODIFY
WalletWasabi.Fluent/ViewModels/AddWallet/LegalDocumentsViewModel.cs
WalletWasabi.Fluent/ViewModels/AddWallet/LegalDocumentsViewModel.cs
+using ReactiveUI;
using WalletWasabi.Fluent.ViewModels.Navigation;
+using WalletWasabi.Legal;
+using WalletWasabi.Services;
+using System;
+using System.Reactive.Linq;
+using System.Reactive.Disposables;
namespace WalletWasabi.Fluent.ViewModels.AddWallet
{
@@ -12,13 +18,47 @@ namespace WalletWasabi.Fluent.ViewModels.AddWallet
NavigationTarget = NavigationTarget.DialogScreen)]
public partial class LegalDocumentsViewModel : RoutableViewModel
{
- public LegalDocumentsViewModel(string content)
+ [AutoNotify] private string? _content;
+
+ private LegalChecker LegalChecker { get; }
+
+ public LegalDocumentsViewModel(LegalChecker legalChecker)
{
Title = "Terms and Conditions";
- Content = content;
NextCommand = BackCommand;
+ LegalChecker = legalChecker;
}
- public string Content { get; }
+ protected override void OnNavigatedTo(bool inStack, CompositeDisposable disposable)
+ {
+ base.OnNavigatedTo(inStack, disposable);
+
+ Observable.Merge(
+ Observable.FromEventPattern<LegalDocuments>(LegalChecker, nameof(LegalChecker.AgreedChanged)),
+ Observable.FromEventPattern<LegalDocuments>(LegalChecker, nameof(LegalChecker.ProvisionalChanged)))
+ .Select(x => x.EventArgs)
+ .ObserveOn(RxApp.MainThreadScheduler)
+ .Subscribe(legalDocuments => UpdateContent(legalDocuments))
+ .DisposeWith(disposable);
+
+ if (LegalChecker.TryGetNewLegalDocs(out LegalDocuments? provisional))
+ {
+ UpdateContent(provisional);
+ }
+ else if (LegalChecker.CurrentLegalDocument is { } current)
+ {
+ UpdateContent(current);
+ }
+ else
+ {
+ //TODO: display busy logic.
+ Content = "Loading...";
+ }
+ }
+
+ private void UpdateContent(LegalDocuments legalDocuments)
+ {
+ Content = legalDocuments.Content;
+ }
}
}

MODIFY
WalletWasabi.Fluent/ViewModels/AddWallet/TermsAndConditionsViewModel.cs
WalletWasabi.Fluent/ViewModels/AddWallet/TermsAndConditionsViewModel.cs
-using System.IO;
using System.Reactive.Linq;
using System.Windows.Input;
using ReactiveUI;
using WalletWasabi.Fluent.ViewModels.Navigation;
-using WalletWasabi.Legal;
+using WalletWasabi.Services;
namespace WalletWasabi.Fluent.ViewModels.AddWallet
{
@@ -11,22 +10,26 @@ namespace WalletWasabi.Fluent.ViewModels.AddWallet
{
[AutoNotify] private bool _isAgreed;
- public TermsAndConditionsViewModel(LegalDocuments legalDocuments, RoutableViewModel next)
+ public TermsAndConditionsViewModel(LegalChecker legalChecker, RoutableViewModel next)
{
Title = "Welcome to Wasabi Wallet";
- ViewTermsCommand = ReactiveCommand.CreateFromTask(
- async () =>
+ ViewTermsCommand = ReactiveCommand.Create(
+ () =>
{
- var content = await File.ReadAllTextAsync(legalDocuments.FilePath);
-
- var legalDocs = new LegalDocumentsViewModel(content);
-
+ if (legalChecker.TryGetNewLegalDocs(out _))
+ {
+ var legalDocs = new LegalDocumentsViewModel(legalChecker);
Navigate().To(legalDocs);
+ }
});
- NextCommand = ReactiveCommand.Create(
- () => Navigate().BackTo(next),
+ NextCommand = ReactiveCommand.CreateFromTask(
+ async () =>
+ {
+ await legalChecker.AgreeAsync();
+ Navigate().To(next, NavigationMode.Clear);
+ },
this.WhenAnyValue(x => x.IsAgreed).ObserveOn(RxApp.MainThreadScheduler));
}

MODIFY
WalletWasabi.Fluent/ViewModels/MainViewModel.cs
WalletWasabi.Fluent/ViewModels/MainViewModel.cs
@@ -69,7 +69,6 @@ namespace WalletWasabi.Fluent.ViewModels
_walletManager = new WalletManagerViewModel(global.WalletManager, global.UiConfig);
_addWalletPage = new AddWalletPageViewModel(
- global.LegalDocuments,
global.WalletManager,
global.BitcoinStore,
global.Network);
@@ -196,15 +195,7 @@ namespace WalletWasabi.Fluent.ViewModels
return null;
});
- LegalDocumentsViewModel.RegisterAsyncLazy(
- async () =>
- {
- var content = await File.ReadAllTextAsync(_global.LegalDocuments.FilePath);
-
- var legalDocs = new LegalDocumentsViewModel(content);
-
- return legalDocs;
- });
+ LegalDocumentsViewModel.RegisterLazy(() => new LegalDocumentsViewModel(_global.LegalChecker));
UserSupportViewModel.RegisterLazy(() => new UserSupportViewModel());
BugReportLinkViewModel.RegisterLazy(() => new BugReportLinkViewModel());

MODIFY
WalletWasabi.Gui/Global.cs
WalletWasabi.Gui/Global.cs
-using Avalonia;
-using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Controls.Notifications;
-using Avalonia.Threading;
using Microsoft.Extensions.Caching.Memory;
using NBitcoin;
using NBitcoin.Protocol;
@@ -30,7 +27,6 @@ using WalletWasabi.Gui.Helpers;
using WalletWasabi.Gui.Models;
using WalletWasabi.Gui.Rpc;
using WalletWasabi.Helpers;
-using WalletWasabi.Legal;
using WalletWasabi.Logging;
using WalletWasabi.Services;
using WalletWasabi.Services.Terminate;
@@ -50,7 +46,7 @@ namespace WalletWasabi.Gui
public string DataDir { get; }
public TorSettings TorSettings { get; }
public BitcoinStore BitcoinStore { get; }
- public LegalDocuments LegalDocuments { get; set; }
+ public LegalChecker LegalChecker { get; private set; }
public Config Config { get; }
public string AddressManagerFilePath { get; private set; }
@@ -89,8 +85,6 @@ namespace WalletWasabi.Gui
HostedServices = new HostedServices();
WalletManager = walletManager;
- LegalDocuments = LegalDocuments.TryLoadAgreed(DataDir);
-
WalletManager.OnDequeue += WalletManager_OnDequeue;
WalletManager.WalletRelevantTransactionProcessed += WalletManager_WalletRelevantTransactionProcessed;
@@ -107,6 +101,7 @@ namespace WalletWasabi.Gui
: new WasabiClientFactory(torEndPoint: null, backendUriGetter: () => Config.GetFallbackBackendUri());
Synchronizer = new WasabiSynchronizer(Network, BitcoinStore, wasabiClientFactory);
+ LegalChecker = new(DataDir);
}
}
@@ -141,6 +136,10 @@ namespace WalletWasabi.Gui
var userAgent = Constants.UserAgents.RandomElement();
var connectionParameters = new NodeConnectionParameters { UserAgent = userAgent };
+ UpdateChecker updateChecker = new(TimeSpan.FromMinutes(7), Synchronizer);
+ await LegalChecker.InitializeAsync(updateChecker).ConfigureAwait(false);
+ HostedServices.Register(updateChecker, "Software Update Checker");
+
HostedServices.Register(new UpdateChecker(TimeSpan.FromMinutes(7), Synchronizer), "Software Update Checker");
HostedServices.Register(new SystemAwakeChecker(WalletManager), "System Awake Checker");
@@ -645,6 +644,14 @@ namespace WalletWasabi.Gui
Logger.LogInfo($"{nameof(CoinJoinProcessor)} is disposed.");
}
+ Logger.LogDebug($"Step: {nameof(LegalChecker)}.", nameof(Global));
+
+ if (LegalChecker is { } legalChecker)
+ {
+ legalChecker.Dispose();
+ Logger.LogInfo($"Disposed {nameof(LegalChecker)}.");
+ }
+
Logger.LogDebug($"Step: {nameof(HostedServices)}.", nameof(Global));
if (HostedServices is { } backgroundServices)

MODIFY
WalletWasabi.Tests/UnitTests/LegalTests.cs
WalletWasabi.Tests/UnitTests/LegalTests.cs
using System;
using System.IO;
+using System.Linq;
using System.Threading.Tasks;
using WalletWasabi.Legal;
+using WalletWasabi.Services;
using WalletWasabi.Tests.Helpers;
using Xunit;
@@ -9,68 +11,24 @@ namespace WalletWasabi.Tests.UnitTests
{
public class LegalTests
{
- [Fact]
- public void CanCreateLegalDocuments()
- {
- var version = new Version(1, 1);
- var legal = new LegalDocuments($"{version}.txt");
- Assert.Equal("1.1.txt", legal.FilePath);
- Assert.Equal(version, legal.Version);
- }
-
- [Fact]
- public void LegalDocumentsCreationFixesPath()
- {
- var legal = new LegalDocuments($" 1.1.txt ");
- Assert.Equal("1.1.txt", legal.FilePath);
- }
-
- [Fact]
- public void LegalDocumentsCreationThrows()
- {
- Assert.Throws<ArgumentException>(() => new LegalDocuments("foo"));
- Assert.Throws<ArgumentException>(() => new LegalDocuments("foo.txt"));
- Assert.Throws<ArgumentException>(() => new LegalDocuments("0.txt"));
- Assert.Throws<ArgumentNullException>(() => new LegalDocuments(null));
- Assert.Throws<ArgumentException>(() => new LegalDocuments(""));
- Assert.Throws<ArgumentException>(() => new LegalDocuments(" "));
- }
-
- [Fact]
- public void CantLoadNotAgreed()
- {
- var dir = Common.GetWorkDir();
- if (Directory.Exists(dir))
- {
- Directory.Delete(dir, true);
- }
-
- var res = LegalDocuments.TryLoadAgreed(dir);
- Assert.Null(res);
- Assert.True(Directory.Exists(dir));
- Assert.Empty(Directory.GetFiles(dir));
- var legalDir = Assert.Single(Directory.GetDirectories(dir));
- Assert.Empty(Directory.GetFiles(legalDir));
- Assert.Empty(Directory.GetDirectories(legalDir));
- }
-
[Fact]
public async Task LeavesTrashAloneAsync()
{
- var dir = Common.GetWorkDir();
- if (Directory.Exists(dir))
+ var legalDir = Common.GetWorkDir();
+ if (Directory.Exists(legalDir))
{
- Directory.Delete(dir, true);
+ await IoHelpers.TryDeleteDirectoryAsync(legalDir);
}
- var res = LegalDocuments.TryLoadAgreed(dir);
+ Directory.CreateDirectory(legalDir);
+
+ var res = await LegalDocuments.LoadAgreedAsync(legalDir);
Assert.Null(res);
- var legalDir = Assert.Single(Directory.GetDirectories(dir));
// Leaves one trash alone.
var trash1 = File.Create(Path.Combine(legalDir, "foo"));
await trash1.DisposeAsync();
- res = LegalDocuments.TryLoadAgreed(dir);
+ res = await LegalDocuments.LoadAgreedAsync(legalDir);
Assert.Null(res);
Assert.Single(Directory.GetFiles(legalDir));
Assert.Empty(Directory.GetDirectories(legalDir));
@@ -80,7 +38,7 @@ namespace WalletWasabi.Tests.UnitTests
var trash3 = File.Create(Path.Combine(legalDir, "foo2"));
await trash2.DisposeAsync();
await trash3.DisposeAsync();
- res = LegalDocuments.TryLoadAgreed(dir);
+ res = await LegalDocuments.LoadAgreedAsync(legalDir);
Assert.Null(res);
Assert.Equal(3, Directory.GetFiles(legalDir).Length);
Assert.Empty(Directory.GetDirectories(legalDir));
@@ -89,22 +47,23 @@ namespace WalletWasabi.Tests.UnitTests
[Fact]
public async Task ResolvesConflictsAsync()
{
- var dir = Common.GetWorkDir();
- if (Directory.Exists(dir))
+ var legalDir = Common.GetWorkDir();
+ if (Directory.Exists(legalDir))
{
- Directory.Delete(dir, true);
+ await IoHelpers.TryDeleteDirectoryAsync(legalDir);
}
- var res = LegalDocuments.TryLoadAgreed(dir);
+ Directory.CreateDirectory(legalDir);
+
+ var res = await LegalDocuments.LoadAgreedAsync(legalDir);
Assert.Null(res);
- var legalDir = Assert.Single(Directory.GetDirectories(dir));
// Deletes them if multiple legal docs found.
var candidate1 = File.Create(Path.Combine(legalDir, "1.1.txt"));
var candidate2 = File.Create(Path.Combine(legalDir, "1.2.txt"));
await candidate1.DisposeAsync();
await candidate2.DisposeAsync();
- res = LegalDocuments.TryLoadAgreed(dir);
+ res = await LegalDocuments.LoadAgreedAsync(legalDir);
Assert.Null(res);
Assert.Empty(Directory.GetFiles(legalDir));
Assert.Empty(Directory.GetDirectories(legalDir));
@@ -116,7 +75,7 @@ namespace WalletWasabi.Tests.UnitTests
await trash.DisposeAsync();
await candidate1.DisposeAsync();
await candidate2.DisposeAsync();
- res = LegalDocuments.TryLoadAgreed(dir);
+ res = await LegalDocuments.LoadAgreedAsync(legalDir);
Assert.Null(res);
Assert.Single(Directory.GetFiles(legalDir));
Assert.Empty(Directory.GetDirectories(legalDir));
@@ -125,53 +84,39 @@ namespace WalletWasabi.Tests.UnitTests
[Fact]
public async Task CanLoadLegalDocsAsync()
{
- var dir = Common.GetWorkDir();
- if (Directory.Exists(dir))
+ var legalDir = Common.GetWorkDir();
+ if (Directory.Exists(legalDir))
{
- Directory.Delete(dir, true);
+ await IoHelpers.TryDeleteDirectoryAsync(legalDir);
}
- var res = LegalDocuments.TryLoadAgreed(dir);
+ Directory.CreateDirectory(legalDir);
+
+ var res = await LegalDocuments.LoadAgreedAsync(legalDir);
Assert.Null(res);
- var legalDir = Assert.Single(Directory.GetDirectories(dir));
var version = new Version(1, 1);
// Deletes them if multiple legal docs found.
var candidate = File.Create(Path.Combine(legalDir, $"{version}.txt"));
await candidate.DisposeAsync();
- res = LegalDocuments.TryLoadAgreed(dir);
+ res = await LegalDocuments.LoadAgreedAsync(legalDir);
Assert.NotNull(res);
Assert.Single(Directory.GetFiles(legalDir));
Assert.Empty(Directory.GetDirectories(legalDir));
- Assert.Equal(version, res.Version);
- Assert.Equal(Path.Combine(legalDir, $"{version}.txt"), res.FilePath);
+ Assert.Equal(version, res?.Version);
}
[Fact]
public async Task CanSerializeFileAsync()
{
- var dir = Common.GetWorkDir();
- if (Directory.Exists(dir))
- {
- Directory.Delete(dir, true);
- }
-
- string filePath = Path.Combine(dir, "1.1.txt");
- var legal = new LegalDocuments(filePath);
-
- Assert.False(File.Exists(filePath));
- await legal.ToFileAsync("bar");
- Assert.True(File.Exists(filePath));
-
- string filePath2 = Path.Combine(dir, "1.2.txt");
- var legal2 = new LegalDocuments(filePath2);
+ Version version = new(1, 1);
+ LegalDocuments legal = new(version, "Don't trust, verify!");
+ string legalFolderPath = Path.Combine(Common.GetWorkDir(), LegalChecker.LegalFolderName);
+ await legal.ToFileAsync(legalFolderPath);
- Assert.True(File.Exists(filePath));
- Assert.False(File.Exists(filePath2));
- await legal2.ToFileAsync("bar");
- Assert.False(File.Exists(filePath));
- Assert.True(File.Exists(filePath2));
+ string expectedFilePath = $"{Path.Combine(legalFolderPath, version.ToString())}.txt";
+ Assert.True(File.Exists(expectedFilePath));
}
}
}

MODIFY
WalletWasabi/Legal/LegalDocuments.cs
WalletWasabi/Legal/LegalDocuments.cs
@@ -56,6 +56,7 @@ namespace WalletWasabi.Legal
{
return Enumerable.Empty<string>();
}
+
return Directory
.EnumerateFiles(folderPath, "*.txt", SearchOption.TopDirectoryOnly)
.Where(x => Version.TryParse(Path.GetFileNameWithoutExtension(x), out _));
@@ -80,6 +81,7 @@ namespace WalletWasabi.Legal
folderPath = Guard.NotNullOrEmptyOrWhitespace(nameof(folderPath), folderPath);
IoHelpers.EnsureDirectoryExists(folderPath);
var filePath = Path.Combine(folderPath, $"{Version}.txt");
+
if (filePath is null)
{
throw new InvalidOperationException($"Invalid {nameof(folderPath)}.");

ADD
WalletWasabi/Services/LegalChecker.cs
+using Nito.AsyncEx;
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using WalletWasabi.Legal;
+using WalletWasabi.Logging;
+using WalletWasabi.Models;
+
+namespace WalletWasabi.Services
+{
+ public class LegalChecker : IDisposable
+ {
+ public const string LegalFolderName = "Legal";
+ public const string ProvisionalLegalFolderName = "Provisional";
+
+ private bool _disposedValue;
+
+ public LegalChecker(string dataDir)
+ {
+ LegalFolder = Path.Combine(dataDir, LegalFolderName);
+ ProvisionalLegalFolder = Path.Combine(LegalFolder, ProvisionalLegalFolderName);
+ }
+
+ public event EventHandler<LegalDocuments>? AgreedChanged;
+
+ public event EventHandler<LegalDocuments>? ProvisionalChanged;
+
+ /// <remarks>Lock object to guard <see cref="CurrentLegalDocument"/> and <see cref="ProvisionalLegalDocument"/> property.</remarks>
+ private AsyncLock LegalDocumentLock { get; } = new();
+
+ private UpdateChecker? UpdateChecker { get; set; }
+ public string LegalFolder { get; }
+ public string ProvisionalLegalFolder { get; }
+ public LegalDocuments? CurrentLegalDocument { get; private set; }
+ private LegalDocuments? ProvisionalLegalDocument { get; set; }
+
+ public async Task InitializeAsync(UpdateChecker updateChecker)
+ {
+ UpdateChecker = updateChecker;
+ UpdateChecker.UpdateStatusChanged += UpdateChecker_UpdateStatusChangedAsync;
+ CurrentLegalDocument = await LegalDocuments.LoadAgreedAsync(LegalFolder).ConfigureAwait(false);
+ ProvisionalLegalDocument = await LegalDocuments.LoadAgreedAsync(ProvisionalLegalFolder).ConfigureAwait(false);
+ }
+
+ public bool TryGetNewLegalDocs([NotNullWhen(true)] out LegalDocuments? legalDocuments)
+ {
+ legalDocuments = null;
+
+ if (ProvisionalLegalDocument is { } legal)
+ {
+ legalDocuments = legal;
+ return true;
+ }
+
+ return false;
+ }
+
+ private async void UpdateChecker_UpdateStatusChangedAsync(object? _, UpdateStatus updateStatus)
+ {
+ try
+ {
+ LegalDocuments? provisionalLegalDocument = null;
+
+ using (await LegalDocumentLock.LockAsync().ConfigureAwait(false))
+ {
+ // If we don't have it or there is a new one.
+ if (CurrentLegalDocument is null || CurrentLegalDocument.Version < updateStatus.LegalDocumentsVersion)
+ {
+ // UpdateChecker cannot be null as the event called by it.
+ var content = await UpdateChecker!.WasabiClient.GetLegalDocumentsAsync(CancellationToken.None).ConfigureAwait(false);
+
+ // Save it as a provisional legal document.
+ provisionalLegalDocument = new(updateStatus.LegalDocumentsVersion, content);
+ await provisionalLegalDocument.ToFileAsync(ProvisionalLegalFolder).ConfigureAwait(false);
+
+ ProvisionalLegalDocument = provisionalLegalDocument;
+ }
+ }
+
+ if (provisionalLegalDocument is { })
+ {
+ ProvisionalChanged?.Invoke(this, provisionalLegalDocument);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError("Could not get legal documents.", ex);
+ }
+ }
+
+ public async Task AgreeAsync()
+ {
+ using (await LegalDocumentLock.LockAsync().ConfigureAwait(false))
+ {
+ if (ProvisionalLegalDocument is not { } provisionalLegalDocument || string.IsNullOrEmpty(provisionalLegalDocument.Content))
+ {
+ throw new InvalidOperationException("Cannot agree the new legal document.");
+ }
+
+ await provisionalLegalDocument.ToFileAsync(LegalFolder).ConfigureAwait(false);
+ LegalDocuments.RemoveCandidates(ProvisionalLegalFolder);
+
+ CurrentLegalDocument = ProvisionalLegalDocument;
+ ProvisionalLegalDocument = null;
+ }
+
+ AgreedChanged?.Invoke(this, CurrentLegalDocument);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!_disposedValue)
+ {
+ if (disposing)
+ {
+ if (UpdateChecker is { } updateChecker)
+ {
+ updateChecker.UpdateStatusChanged -= UpdateChecker_UpdateStatusChangedAsync;
+ }
+ }
+
+ _disposedValue = true;
+ }
+ }
+
+ public void Dispose()
+ {
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+ }
+}

Add LegalChecker class