From 2ddc0f920c049707682c27616ce00e5043482db5 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sat, 13 Jun 2026 21:15:03 +0200 Subject: [PATCH] refactor(app): split MainWindowViewModel into partial files by area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1300-line monolith → 7 focused files (max 359 lines each): MainWindowViewModel.cs core fields, constructor, lifecycle MainWindowViewModel.Wizard.cs setup wizard and wallet open/close flows MainWindowViewModel.Settings.cs language, unit, overlay flags MainWindowViewModel.Sync.cs server config, connection, sync loop MainWindowViewModel.Send.cs send panel MainWindowViewModel.Contacts.cs contact management MainWindowViewModel.Receive.cs balance display, QR, addresses, tx details No behaviour change, no XAML change, 127/127 tests pass. --- .../MainWindowViewModel.Contacts.cs | 70 + .../ViewModels/MainWindowViewModel.Receive.cs | 221 +++ .../ViewModels/MainWindowViewModel.Send.cs | 99 ++ .../MainWindowViewModel.Settings.cs | 74 + .../ViewModels/MainWindowViewModel.Sync.cs | 258 ++++ .../ViewModels/MainWindowViewModel.Wizard.cs | 360 +++++ src/App/ViewModels/MainWindowViewModel.cs | 1227 +---------------- 7 files changed, 1140 insertions(+), 1169 deletions(-) create mode 100644 src/App/ViewModels/MainWindowViewModel.Contacts.cs create mode 100644 src/App/ViewModels/MainWindowViewModel.Receive.cs create mode 100644 src/App/ViewModels/MainWindowViewModel.Send.cs create mode 100644 src/App/ViewModels/MainWindowViewModel.Settings.cs create mode 100644 src/App/ViewModels/MainWindowViewModel.Sync.cs create mode 100644 src/App/ViewModels/MainWindowViewModel.Wizard.cs diff --git a/src/App/ViewModels/MainWindowViewModel.Contacts.cs b/src/App/ViewModels/MainWindowViewModel.Contacts.cs new file mode 100644 index 0000000..7eba20c --- /dev/null +++ b/src/App/ViewModels/MainWindowViewModel.Contacts.cs @@ -0,0 +1,70 @@ +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PalladiumWallet.Core.Storage; + +namespace PalladiumWallet.App.ViewModels; + +public partial class MainWindowViewModel +{ + public ObservableCollection Contacts { get; } = []; + + [ObservableProperty] + private ContactEntry? selectedContactInList; + + /// Contatto selezionato nella ComboBox del pannello Invia: riempie SendTo. + [ObservableProperty] + private ContactEntry? sendToContact; + + partial void OnSendToContactChanged(ContactEntry? value) + { + if (value is not null) + SendTo = value.Address; + } + + [ObservableProperty] + private string newContactName = ""; + + [ObservableProperty] + private string newContactAddress = ""; + + [RelayCommand] + private void AddContact() + { + var name = NewContactName.Trim(); + var addr = NewContactAddress.Trim(); + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(addr)) return; + Contacts.Add(new ContactEntry(name, addr)); + NewContactName = NewContactAddress = ""; + PersistContacts(); + } + + [RelayCommand] + private void RemoveSelectedContact() + { + if (SelectedContactInList is { } c) + { + Contacts.Remove(c); + SelectedContactInList = null; + PersistContacts(); + } + } + + private void PersistContacts() + { + if (_doc is null || _walletPath is null) return; + _doc.Contacts = Contacts + .Select(c => new StoredContact { Name = c.Name, Address = c.Address }) + .ToList(); + WalletStore.Save(_doc, _walletPath, _password); + } + + private void LoadContacts() + { + Contacts.Clear(); + if (_doc is null) return; + foreach (var c in _doc.Contacts) + Contacts.Add(new ContactEntry(c.Name, c.Address)); + } +} diff --git a/src/App/ViewModels/MainWindowViewModel.Receive.cs b/src/App/ViewModels/MainWindowViewModel.Receive.cs new file mode 100644 index 0000000..ec40e6b --- /dev/null +++ b/src/App/ViewModels/MainWindowViewModel.Receive.cs @@ -0,0 +1,221 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PalladiumWallet.App.Localization; +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Spv; +using PalladiumWallet.Core.Storage; +using PalladiumWallet.Core.Wallet; +using QRCoder; + +namespace PalladiumWallet.App.ViewModels; + +public partial class MainWindowViewModel +{ + // ---- saldo e info wallet ---- + + [ObservableProperty] + private string balanceText = "—"; + + [ObservableProperty] + private string unconfirmedText = ""; + + [ObservableProperty] + private string networkInfo = ""; + + // ---- indirizzo di ricezione e QR ---- + + [ObservableProperty] + private string receiveAddress = ""; + + [ObservableProperty] + private Bitmap? receiveQr; + + partial void OnReceiveAddressChanged(string value) + { + var previous = ReceiveQr; + ReceiveQr = string.IsNullOrEmpty(value) ? null : GenerateQr(value); + previous?.Dispose(); + } + + public void NotifyAddressCopied() => StatusMessage = Loc.Tr("addr.copied"); + + private static Bitmap? GenerateQr(string text) + { + try + { + using var generator = new QRCodeGenerator(); + using var data = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.M); + var png = new PngByteQRCode(data).GetGraphic(8); + return new Bitmap(new MemoryStream(png)); + } + catch + { + return null; + } + } + + // ---- tab indirizzi ---- + + [ObservableProperty] + private AddressRow? selectedAddressRow; + + [ObservableProperty] + private AddressInfo? addressInfo; + + public void ShowAddressInfo(AddressRow row) => + AddressInfo = new AddressInfo(Loc, row.Indirizzo, row.DerivPath, row.PubKey, row.PrivKey); + + [RelayCommand] + private void CloseAddressInfo() => AddressInfo = null; + + // ---- overlay dettaglio transazione ---- + + [ObservableProperty] + private bool isTxDetailsOpen; + + [ObservableProperty] + private bool isTxDetailsLoading; + + [ObservableProperty] + private TransactionDetailsViewModel? txDetails; + + public async Task ShowTransactionDetailsAsync(string txid) + { + if (_client is null || !_client.IsConnected) + { + StatusMessage = Loc.Tr("tx.needconnection"); + return; + } + + _txDetailsCts?.Cancel(); + _txDetailsCts = new CancellationTokenSource(); + var ct = _txDetailsCts.Token; + + TxDetails = null; + IsTxDetailsLoading = true; + IsTxDetailsOpen = true; + + var details = await BuildTransactionDetailsAsync(txid, ct); + + if (ct.IsCancellationRequested || !IsTxDetailsOpen) + return; + + if (details is null) + { + IsTxDetailsOpen = false; + IsTxDetailsLoading = false; + return; + } + + TxDetails = details; + IsTxDetailsLoading = false; + } + + [RelayCommand] + private void CloseTransactionDetails() + { + _txDetailsCts?.Cancel(); + IsTxDetailsOpen = false; + IsTxDetailsLoading = false; + TxDetails = null; + } + + public async Task BuildTransactionDetailsAsync( + string txid, CancellationToken ct = default) + { + if (_client is null || !_client.IsConnected) + { + StatusMessage = Loc.Tr("tx.needconnection"); + return null; + } + if (_doc?.Cache is not { } cache) + return null; + + var client = _client; + var network = PalladiumNetworks.For(Net); + var row = cache.History.FirstOrDefault(t => t.Txid == txid); + var owned = cache.Addresses.Select(a => a.Address).ToHashSet(); + var tipHeight = cache.TipHeight; + var height = row?.Height ?? 0; + var delta = row?.DeltaSats ?? 0; + var verified = row?.Verified ?? false; + var transactions = _lastTransactions; + var loc = _loc; + var unit = _config.Unit; + + try + { + return await Task.Run(async () => + { + var details = await TransactionInspector.FetchAsync( + client, network, txid, tipHeight, height, owned, delta, verified, transactions, ct); + return new TransactionDetailsViewModel(details, loc, unit); + }, ct); + } + catch (OperationCanceledException) + { + return null; + } + catch (Exception ex) + { + StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}"; + return null; + } + } + + // ---- aggiorna display dal risultato della sync ---- + + private void ApplyCache(SyncCache? cache) + { + if (_account is null) + return; + if (cache is null) + { + BalanceText = $"0.00000000 {Profile.CoinUnit}"; + UnconfirmedText = ""; + ReceiveAddress = _account.GetReceiveAddress(0).ToString(); + History.Clear(); + Addresses.Clear(); + for (var i = 0; i < 10; i++) + Addresses.Add(new AddressRow(_loc["addr.receive"], i, + _account.GetReceiveAddress(i).ToString(), "—", "—", + false, + _account.GetPublicKey(false, i).ToHex(), + KeyWif(false, i), + $"m/{_doc!.AccountPath}/0/{i}")); + return; + } + BalanceText = Fmt(cache.ConfirmedSats); + var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats); + UnconfirmedText = pending != 0 + ? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}" + : ""; + ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString(); + History.Clear(); + foreach (var tx in cache.History) + History.Add(new HistoryRow( + tx.Height > 0 ? tx.Height.ToString() : "mempool", + (tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false), + tx.Txid, + tx.Verified ? "✓ SPV" : "—")); + + Addresses.Clear(); + foreach (var a in cache.Addresses) + Addresses.Add(new AddressRow( + a.IsChange ? _loc["addr.change"] : _loc["addr.receive"], + a.Index, + a.Address, + a.BalanceSats > 0 ? Fmt(a.BalanceSats, withLabel: false) : (a.TxCount > 0 ? "0" : "—"), + a.TxCount > 0 ? a.TxCount.ToString() : "—", + a.IsChange, + _account.GetPublicKey(a.IsChange, a.Index).ToHex(), + KeyWif(a.IsChange, a.Index), + $"m/{_doc!.AccountPath}/{(a.IsChange ? 1 : 0)}/{a.Index}")); + } +} diff --git a/src/App/ViewModels/MainWindowViewModel.Send.cs b/src/App/ViewModels/MainWindowViewModel.Send.cs new file mode 100644 index 0000000..d2e341c --- /dev/null +++ b/src/App/ViewModels/MainWindowViewModel.Send.cs @@ -0,0 +1,99 @@ +using System; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using NBitcoin; +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Net; +using PalladiumWallet.Core.Wallet; + +namespace PalladiumWallet.App.ViewModels; + +public partial class MainWindowViewModel +{ + [ObservableProperty] + private string sendTo = ""; + + [ObservableProperty] + private string sendAmount = ""; + + [ObservableProperty] + private string sendFeeRate = "1"; + + [ObservableProperty] + private bool sendAll; + + [ObservableProperty] + private string sendPreview = ""; + + [ObservableProperty] + private bool hasPendingSend; + + [RelayCommand] + private async Task PrepareSend() + { + if (_account is null || _doc?.Cache is null) + { + SendPreview = "Sincronizza prima di inviare."; + return; + } + try + { + if (_lastTransactions is null) + { + SendPreview = "Connettiti al server e sincronizza prima di inviare."; + return; + } + + var destination = BitcoinAddress.Create(SendTo.Trim(), PalladiumNetworks.For(Net)); + long amount = 0; + if (!SendAll && !CoinAmount.TryParseIn(SendAmount, _config.Unit, out amount)) + { + SendPreview = "Importo non valido."; + return; + } + if (!decimal.TryParse(SendFeeRate, out var feeRate) || feeRate <= 0) + { + SendPreview = "Fee rate non valido."; + return; + } + + _pendingSend = new TransactionFactory(_account).Build( + _doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate, + _doc.Cache.NextChangeIndex, SendAll); + + SendPreview = $"txid {_pendingSend.Txid[..16]}… · " + + $"fee {Fmt(_pendingSend.Fee.Satoshi)} " + + $"({_pendingSend.Transaction.GetVirtualSize()} vB)" + + (_pendingSend.Signed ? "" : " · NON firmata (watch-only)"); + HasPendingSend = _pendingSend.Signed; + } + catch (Exception ex) + { + _pendingSend = null; + HasPendingSend = false; + SendPreview = $"Errore: {ex.Message}"; + } + await Task.CompletedTask; + } + + [RelayCommand] + private async Task ConfirmSend() + { + if (_pendingSend is null || _client is null) + return; + try + { + var txid = await _client.BroadcastAsync(_pendingSend.ToHex()); + SendPreview = $"Trasmessa: {txid}"; + SendTo = SendAmount = ""; + _pendingSend = null; + HasPendingSend = false; + await ConnectAndSync(); + } + catch (Exception ex) + { + SendPreview = $"Errore broadcast: {ex.Message}"; + } + } +} diff --git a/src/App/ViewModels/MainWindowViewModel.Settings.cs b/src/App/ViewModels/MainWindowViewModel.Settings.cs new file mode 100644 index 0000000..778e591 --- /dev/null +++ b/src/App/ViewModels/MainWindowViewModel.Settings.cs @@ -0,0 +1,74 @@ +using CommunityToolkit.Mvvm.Input; + +namespace PalladiumWallet.App.ViewModels; + +public partial class MainWindowViewModel +{ + // ---- spunte del menu Impostazioni (ToggleType Radio) ---- + + public bool IsLangIt => _config.Language == "it"; + public bool IsLangEn => _config.Language == "en"; + public bool IsLangEs => _config.Language == "es"; + public bool IsLangFr => _config.Language == "fr"; + public bool IsLangPt => _config.Language == "pt"; + public bool IsLangDe => _config.Language == "de"; + public bool IsUnitPlm => _config.Unit == "PLM"; + public bool IsUnitMilli => _config.Unit == "mPLM"; + public bool IsUnitMicro => _config.Unit == "µPLM"; + public bool IsUnitSat => _config.Unit == "sat"; + + [RelayCommand] + private void SetLanguage(string language) + { + _config.Language = language; + ApplySettings(_config); + } + + [RelayCommand] + private void SetUnit(string unit) + { + _config.Unit = unit; + ApplySettings(_config); + } + + public void ApplySettings(PalladiumWallet.Core.Storage.AppConfig config) + { + _config = config; + _config.Save(); + _loc = Localization.Loc.SwitchTo(config.Language); + OnPropertyChanged(nameof(Loc)); + OnPropertyChanged(nameof(UnitLabel)); + OnPropertyChanged(nameof(IsLangIt)); + OnPropertyChanged(nameof(IsLangEn)); + OnPropertyChanged(nameof(IsLangEs)); + OnPropertyChanged(nameof(IsLangFr)); + OnPropertyChanged(nameof(IsLangPt)); + OnPropertyChanged(nameof(IsLangDe)); + OnPropertyChanged(nameof(IsUnitPlm)); + OnPropertyChanged(nameof(IsUnitMilli)); + OnPropertyChanged(nameof(IsUnitMicro)); + OnPropertyChanged(nameof(IsUnitSat)); + ApplyCache(_doc?.Cache); + StatusMessage = Localization.Loc.Tr("msg.settings.saved"); + } + + // ---- overlay impostazioni, server, help ---- + + [CommunityToolkit.Mvvm.ComponentModel.ObservableProperty] + private bool isSettingsOpen; + + [RelayCommand] + private void OpenSettings() => IsSettingsOpen = true; + + [RelayCommand] + private void CloseSettings() => IsSettingsOpen = false; + + [CommunityToolkit.Mvvm.ComponentModel.ObservableProperty] + private bool isHelpOpen; + + [RelayCommand] + private void OpenHelp() => IsHelpOpen = true; + + [RelayCommand] + private void CloseHelp() => IsHelpOpen = false; +} diff --git a/src/App/ViewModels/MainWindowViewModel.Sync.cs b/src/App/ViewModels/MainWindowViewModel.Sync.cs new file mode 100644 index 0000000..9d85df2 --- /dev/null +++ b/src/App/ViewModels/MainWindowViewModel.Sync.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PalladiumWallet.App.Localization; +using PalladiumWallet.Core.Net; +using PalladiumWallet.Core.Spv; +using PalladiumWallet.Core.Storage; + +namespace PalladiumWallet.App.ViewModels; + +public partial class MainWindowViewModel +{ + // ---- server e connessione ---- + + [ObservableProperty] + private string serverHost = ""; + + [ObservableProperty] + private string serverPort = ""; + + [ObservableProperty] + private bool useSsl = true; + + [ObservableProperty] + private bool isServerSettingsOpen; + + [RelayCommand] + private void OpenServerSettings() + { + IsSettingsOpen = false; + IsServerSettingsOpen = true; + } + + [RelayCommand] + private void CloseServerSettings() => IsServerSettingsOpen = false; + + [ObservableProperty] + private string connectionStatus = Loc.Tr("conn.none"); + + [ObservableProperty] + private bool isConnected; + + [ObservableProperty] + private bool isSyncing; + + public ObservableCollection KnownServers { get; } = []; + + [ObservableProperty] + private KnownServer? selectedKnownServer; + + partial void OnSelectedKnownServerChanged(KnownServer? value) + { + if (value is null) + return; + _syncingServerFields = true; + ServerHost = value.Host; + ServerPort = value.PortFor(UseSsl).ToString(); + _syncingServerFields = false; + } + + partial void OnUseSslChanged(bool value) + { + if (_syncingServerFields) + return; + _syncingServerFields = true; + ServerPort = SelectedKnownServer is { } server + ? server.PortFor(value).ToString() + : (value ? Profile.DefaultSslPort : Profile.DefaultTcpPort).ToString(); + _syncingServerFields = false; + } + + partial void OnServerPortChanged(string value) + { + if (_syncingServerFields) + return; + if (!int.TryParse(value.Trim(), out var port)) + return; + bool? wantSsl = + SelectedKnownServer is { } s && port == s.SslPort ? true : + SelectedKnownServer is { } t && port == t.TcpPort ? false : + port == Profile.DefaultSslPort ? true : + port == Profile.DefaultTcpPort ? false : + null; + if (wantSsl is bool b && b != UseSsl) + { + _syncingServerFields = true; + UseSsl = b; + _syncingServerFields = false; + } + } + + private void RefreshServers() + { + KnownServers.Clear(); + foreach (var server in Registry.All) + KnownServers.Add(server); + SelectedKnownServer = KnownServers.FirstOrDefault(); + if (SelectedKnownServer is null) + { + ServerHost = "127.0.0.1"; + ServerPort = (UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort).ToString(); + } + } + + private (string Host, int Port) ParseServer() + { + var host = ServerHost.Trim(); + var port = int.TryParse(ServerPort.Trim(), out var p) + ? p + : UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort; + return (host, port); + } + + [RelayCommand] + private async Task ConnectAndSync() + { + if (IsSyncing) + { + _resyncRequested = true; + return; + } + IsSyncing = true; + StatusMessage = ""; + try + { + var (host, port) = ParseServer(); + + if (_client is { } current && + (current.Host != host || current.Port != port || current.UseSsl != UseSsl)) + { + await DisconnectAsync(); + } + + if (_client is null || !_client.IsConnected) + { + ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {host}:{port}…"; + var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net)); + _client = await ElectrumClient.ConnectAsync(host, port, UseSsl, pins); + _client.NotificationReceived += OnServerNotification; + _client.Disconnected += _ => Dispatcher.UIThread.Post(() => + { + IsConnected = false; + ConnectionStatus = Loc.Tr("conn.disconnected"); + }); + IsConnected = true; + _autoReconnect = true; + ConnectionStatus = $"{Loc.Tr("conn.connectedto")} {host}:{port}{(UseSsl ? " (TLS)" : "")}"; + } + + if (_account is null || _doc is null) + return; + + if (_synchronizer is null) + { + _synchronizer = new WalletSynchronizer(_account, _client, _doc.GapLimit); + _synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg); + } + + do + { + _resyncRequested = false; + var result = await _synchronizer.SyncOnceAsync(); + _lastTransactions = result.Transactions; + + _doc.Cache = new SyncCache + { + TipHeight = result.TipHeight, + ConfirmedSats = result.ConfirmedSats, + UnconfirmedSats = result.UnconfirmedSats, + NextReceiveIndex = result.NextReceiveIndex, + NextChangeIndex = result.NextChangeIndex, + History = [.. result.History], + Utxos = [.. result.Utxos], + Addresses = [.. result.AddressRows], + }; + WalletStore.Save(_doc, _walletPath!, _password); + ApplyCache(_doc.Cache); + StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " + + $"{result.History.Count} {Loc.Tr("msg.synced.detail")}"; + } while (_resyncRequested); + } + catch (CertificatePinMismatchException ex) + { + IsConnected = false; + ConnectionStatus = Loc.Tr("conn.certchanged"); + StatusMessage = ex.Message; + } + catch (Exception ex) + { + IsConnected = _client?.IsConnected == true; + ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.error"); + StatusMessage = $"Errore: {ex.Message}"; + } + finally + { + IsSyncing = false; + } + } + + [RelayCommand] + private async Task DiscoverServers() + { + if (_client is null || !_client.IsConnected) + { + StatusMessage = Loc.Tr("conn.none") + "."; + return; + } + try + { + var added = await Registry.DiscoverAsync(_client); + var selected = SelectedKnownServer; + RefreshServers(); + SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == selected?.Host) + ?? KnownServers.FirstOrDefault(); + StatusMessage = added > 0 + ? $"Trovati {added} nuovi server dai peer (totale {KnownServers.Count})." + : $"Nessun nuovo server annunciato (totale {KnownServers.Count})."; + } + catch (Exception ex) + { + StatusMessage = $"Errore nella scoperta peer: {ex.Message}"; + } + } + + private void OnServerNotification(string method, System.Text.Json.JsonElement payload) + { + if (method is "blockchain.scripthash.subscribe" or "blockchain.headers.subscribe") + Dispatcher.UIThread.Post(() => + { + if (IsSyncing) + _resyncRequested = true; + else + _ = ConnectAndSync(); + }); + } + + [RelayCommand] + private void ResetCertificates() + { + new CertificatePinStore(AppPaths.CertificatePinsPath(Net)).ResetAll(); + StatusMessage = Loc.Tr("msg.certreset"); + } + + private async Task DisconnectAsync() + { + if (_client is { } client) + { + _client = null; + _synchronizer = null; + try { await client.DisposeAsync(); } catch { } + } + IsConnected = false; + } +} diff --git a/src/App/ViewModels/MainWindowViewModel.Wizard.cs b/src/App/ViewModels/MainWindowViewModel.Wizard.cs new file mode 100644 index 0000000..595cb5c --- /dev/null +++ b/src/App/ViewModels/MainWindowViewModel.Wizard.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.ObjectModel; +using System.IO; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PalladiumWallet.App.Localization; +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Crypto; +using PalladiumWallet.Core.Storage; +using PalladiumWallet.Core.Wallet; + +namespace PalladiumWallet.App.ViewModels; + +public partial class MainWindowViewModel +{ + // ---- wizard di setup + + public const string StepDataLocation = "data-location"; + public const string StepStart = "start"; + public const string StepChooseWallet = "choose-wallet"; + public const string StepOpen = "open"; + public const string StepShowSeed = "show-seed"; + public const string StepConfirmSeed = "confirm-seed"; + public const string StepWords = "words"; + public const string StepPassphrase = "passphrase"; + public const string StepPassword = "password"; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsStepDataLocation))] + [NotifyPropertyChangedFor(nameof(IsStepStart))] + [NotifyPropertyChangedFor(nameof(IsStepChooseWallet))] + [NotifyPropertyChangedFor(nameof(IsStepOpen))] + [NotifyPropertyChangedFor(nameof(IsStepShowSeed))] + [NotifyPropertyChangedFor(nameof(IsStepConfirmSeed))] + [NotifyPropertyChangedFor(nameof(IsStepWords))] + [NotifyPropertyChangedFor(nameof(IsStepPassphrase))] + [NotifyPropertyChangedFor(nameof(IsStepPassword))] + private string setupStep = StepStart; + + public bool IsStepDataLocation => SetupStep == StepDataLocation; + public bool IsStepStart => SetupStep == StepStart; + public bool IsStepChooseWallet => SetupStep == StepChooseWallet; + public bool IsStepOpen => SetupStep == StepOpen; + public bool IsStepShowSeed => SetupStep == StepShowSeed; + public bool IsStepConfirmSeed => SetupStep == StepConfirmSeed; + public bool IsStepWords => SetupStep == StepWords; + public bool IsStepPassphrase => SetupStep == StepPassphrase; + public bool IsStepPassword => SetupStep == StepPassword; + + public string DefaultDataPath => AppPaths.DefaultDataRoot(); + + [ObservableProperty] + private string mnemonicInput = ""; + + [ObservableProperty] + private string confirmMnemonicInput = ""; + + [ObservableProperty] + private string passphraseInput = ""; + + [ObservableProperty] + private string passwordInput = ""; + + [ObservableProperty] + private string confirmPasswordInput = ""; + + [ObservableProperty] + private bool encryptWallet = true; + + [ObservableProperty] + private bool walletFileExists; + + public ObservableCollection WalletList { get; } = []; + + [RelayCommand] + private void UseDefaultDataLocation() => ApplyDataLocation(AppPaths.DefaultDataRoot()); + + public void ApplyDataLocation(string root) + { + try + { + AppPaths.ConfigureDataLocation(root); + } + catch (Exception ex) + { + StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}"; + return; + } + _config = AppConfig.Load(); + _loc = Loc.SwitchTo(_config.Language); + OnPropertyChanged(nameof(Loc)); + OnPropertyChanged(nameof(UnitLabel)); + RefreshSetupState(); + } + + private void RefreshSetupState() + { + SetupStep = StepStart; + MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = ""; + WalletFileExists = AppPaths.WalletFiles(Net).Count > 0; + StatusMessage = ""; + RefreshServers(); + _ = ConnectAndSync(); + } + + [RelayCommand] + private void WizardStartOpen() + { + var files = AppPaths.WalletFiles(Net); + if (files.Count > 1) + { + WalletList.Clear(); + foreach (var path in files) + WalletList.Add(new WalletFileEntry(Path.GetFileName(path), path)); + SetupStep = StepChooseWallet; + StatusMessage = ""; + return; + } + _pendingOpenPath = files.Count == 1 ? files[0] : AppPaths.DefaultWalletPath(Net); + PasswordInput = ""; + SetupStep = StepOpen; + StatusMessage = ""; + } + + [RelayCommand] + private void ChooseWallet(WalletFileEntry? entry) + { + if (entry is null) + return; + _pendingOpenPath = entry.Path; + PasswordInput = ""; + SetupStep = StepOpen; + StatusMessage = ""; + } + + [RelayCommand] + private void WizardStartNew() + { + _isRestoreFlow = false; + MnemonicInput = Bip39.Generate(MnemonicLength.Twelve).ToString(); + SetupStep = StepShowSeed; + StatusMessage = ""; + } + + [RelayCommand] + private void WizardStartRestore() + { + _isRestoreFlow = true; + MnemonicInput = ""; + SetupStep = StepWords; + StatusMessage = ""; + } + + [RelayCommand] + private void WizardNextFromShowSeed() + { + ConfirmMnemonicInput = ""; + SetupStep = StepConfirmSeed; + StatusMessage = ""; + } + + [RelayCommand] + private void WizardNextFromConfirmSeed() + { + var normalized = string.Join(' ', + ConfirmMnemonicInput.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + if (!string.Equals(normalized, MnemonicInput, StringComparison.OrdinalIgnoreCase)) + { + StatusMessage = Loc.Tr("msg.seed.mismatch"); + return; + } + GoToPassphraseStep(); + } + + [RelayCommand] + private void WizardNextFromWords() + { + if (!Bip39.TryParse(MnemonicInput, out _)) + { + StatusMessage = Loc.Tr("msg.words.invalid"); + return; + } + GoToPassphraseStep(); + } + + private void GoToPassphraseStep() + { + PassphraseInput = ""; + SetupStep = StepPassphrase; + StatusMessage = ""; + } + + [RelayCommand] + private void WizardNextFromPassphrase() + { + PasswordInput = ConfirmPasswordInput = ""; + EncryptWallet = true; + SetupStep = StepPassword; + StatusMessage = ""; + } + + [RelayCommand] + private void WizardBack() + { + SetupStep = SetupStep switch + { + StepOpen => WalletList.Count > 1 ? StepChooseWallet : StepStart, + StepChooseWallet or StepShowSeed or StepWords => StepStart, + StepConfirmSeed => StepShowSeed, + StepPassphrase => _isRestoreFlow ? StepWords : StepConfirmSeed, + StepPassword => StepPassphrase, + _ => StepStart, + }; + if (SetupStep == StepStart) + RefreshSetupState(); + } + + [RelayCommand] + private void CreateOrRestore() + { + string? password; + if (EncryptWallet) + { + if (string.IsNullOrEmpty(PasswordInput)) + { + StatusMessage = Loc.Tr("msg.password.required"); + return; + } + if (PasswordInput != ConfirmPasswordInput) + { + StatusMessage = Loc.Tr("msg.password.mismatch"); + return; + } + password = PasswordInput; + } + else + { + password = null; + } + + try + { + var (doc, account) = WalletLoader.NewFromMnemonic( + MnemonicInput, + string.IsNullOrEmpty(PassphraseInput) ? null : PassphraseInput, + ScriptKind.NativeSegwit, + Profile); + var path = AppPaths.DefaultWalletPath(Net); + for (var n = 2; WalletStore.Exists(path); n++) + path = Path.Combine(AppPaths.WalletsDir(Net), $"wallet-{n}.wallet.json"); + WalletStore.Save(doc, path, password); + var newLock = WalletLock.TryAcquire(path); + if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; } + OpenLoaded(doc, account, path, password, newLock); + } + catch (Exception ex) + { + StatusMessage = $"Errore: {ex.Message}"; + } + } + + [RelayCommand] + private void OpenExisting() + { + var path = _pendingOpenPath ?? AppPaths.DefaultWalletPath(Net); + var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput; + + var newLock = WalletLock.TryAcquire(path); + if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; } + + try + { + var doc = WalletStore.Load(path, password); + _pendingOpenPath = null; + OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password, newLock); + } + catch (WrongPasswordException) + { + newLock.Dispose(); + StatusMessage = Loc.Tr("msg.wrongpassword"); + } + catch (UnauthorizedAccessException) + { + newLock.Dispose(); + StatusMessage = Loc.Tr("msg.wallet.noaccess"); + } + catch (Exception ex) + { + newLock.Dispose(); + StatusMessage = $"Errore: {ex.Message}"; + } + } + + public void OpenFromPath(string path) + { + var newLock = WalletLock.TryAcquire(path); + if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; } + + try + { + var doc = WalletStore.Load(path); + if (IsWalletOpen) + CloseWallet(); + OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password: null, newLock); + } + catch (WrongPasswordException) + { + newLock.Dispose(); + if (IsWalletOpen) + CloseWallet(); + _pendingOpenPath = path; + WalletFileExists = true; + PasswordInput = ""; + SetupStep = StepOpen; + StatusMessage = ""; + } + catch (UnauthorizedAccessException) + { + newLock.Dispose(); + StatusMessage = Loc.Tr("msg.wallet.noaccess"); + } + catch (Exception ex) + { + newLock.Dispose(); + StatusMessage = $"Errore: {ex.Message}"; + } + } + + [RelayCommand] + private void NewWallet() + { + if (IsWalletOpen) + CloseWallet(); + _pendingOpenPath = null; + StatusMessage = ""; + } + + private void OpenLoaded( + WalletDocument doc, HdAccount account, string path, string? password, WalletLock walletLock) + { + _walletLock?.Dispose(); + _walletLock = walletLock; + + SelectedNetwork = doc.Network; + _doc = doc; + _account = account; + _walletPath = path; + _password = password; + MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = ""; + SetupStep = StepStart; + + NetworkInfo = $"{doc.Network} · {doc.ScriptKind} · m/{doc.AccountPath}" + + (doc.IsWatchOnly ? " · watch-only" : ""); + LoadContacts(); + ApplyCache(doc.Cache); + IsWalletOpen = true; + StatusMessage = Loc.Tr("msg.opened"); + _ = ConnectAndSync(); + } +} \ No newline at end of file diff --git a/src/App/ViewModels/MainWindowViewModel.cs b/src/App/ViewModels/MainWindowViewModel.cs index e32cd33..17916fb 100644 --- a/src/App/ViewModels/MainWindowViewModel.cs +++ b/src/App/ViewModels/MainWindowViewModel.cs @@ -1,11 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.IO; -using System.Linq; using System.Threading; -using System.Threading.Tasks; -using Avalonia.Media.Imaging; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -17,7 +13,6 @@ using PalladiumWallet.Core.Net; using PalladiumWallet.Core.Spv; using PalladiumWallet.Core.Storage; using PalladiumWallet.Core.Wallet; -using QRCoder; namespace PalladiumWallet.App.ViewModels; @@ -34,7 +29,7 @@ public sealed record AddressRow( /// Dati completi di un indirizzo passati alla finestra di dettaglio. public sealed record AddressInfo( - Localization.Loc Loc, + Loc Loc, string Address, string DerivPath, string PubKey, string PrivKey) { public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey); @@ -47,105 +42,93 @@ public sealed record ContactEntry(string Name, string Address); public sealed record WalletFileEntry(string Name, string Path); /// -/// ViewModel unico dell'applicazione (wizard §15 ridotto + dashboard): -/// pannello di setup (crea/ripristina/apri) e pannello wallet -/// (saldo, ricevi, storico, invia, server). +/// ViewModel unico dell'applicazione (wizard + dashboard). Suddiviso in file +/// partial per area: Wizard, Settings, Sync, Send, Contacts, Receive. /// public partial class MainWindowViewModel : ViewModelBase { + // ---- stato sessione wallet ---- private WalletDocument? _doc; private HdAccount? _account; private string? _walletPath; private string? _password; private WalletLock? _walletLock; + + // ---- rete ---- private ElectrumClient? _client; private WalletSynchronizer? _synchronizer; private IReadOnlyDictionary? _lastTransactions; - private BuiltTransaction? _pendingSend; - - /// Notifica arrivata durante una sync: si risincronizza appena finita. private bool _resyncRequested; - /// Configurazione globale (§8): lingua e unità. - private AppConfig _config = AppConfig.Load(); + // ---- invio ---- + private BuiltTransaction? _pendingSend; - /// Istanza Loc corrente: viene rimpiazzata ad ogni cambio lingua - /// così Avalonia vede un riferimento diverso e rivaluta le binding {Binding Loc[chiave]}. + // ---- dettaglio transazione ---- + private CancellationTokenSource? _txDetailsCts; + + // ---- configurazione e localizzazione ---- + private AppConfig _config = AppConfig.Load(); private Loc _loc = Loc.Instance; public Loc Loc => _loc; - /// Versione dell'app (da <Version> nel csproj), formato Major.Minor.Patch. + // ---- wizard ---- + private string? _pendingOpenPath; + private bool _isRestoreFlow; + + // ---- keep-alive ---- + private bool _autoReconnect; + private readonly DispatcherTimer _keepAliveTimer; + + // ---- server UI sync ---- + private bool _syncingServerFields; + + // ---- proprietà di app ---- + public static string AppVersion => typeof(MainWindowViewModel).Assembly.GetName().Version is { } v ? $"{v.Major}.{v.Minor}.{v.Build}" : ""; - /// Titolo della finestra con la versione, es. "Palladium Wallet 0.9.0". public string WindowTitle => $"Palladium Wallet {AppVersion}"; - /// true su desktop (Windows/Linux); false su mobile. Nasconde le funzioni - /// legate al filesystem libero (apri-da-file, scelta cartella dati) non valide nel - /// sandbox Android. + /// true su desktop; false su Android/iOS — nasconde le funzioni legate al filesystem libero. public bool IsDesktop => !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS(); - /// Unità corrente per il campo importo del pannello Invia. public string UnitLabel => _config.Unit; - public AppConfig CurrentConfig => _config; - // Spunte del menu Impostazioni (ToggleType Radio). - public bool IsLangIt => _config.Language == "it"; - public bool IsLangEn => _config.Language == "en"; - public bool IsLangEs => _config.Language == "es"; - public bool IsLangFr => _config.Language == "fr"; - public bool IsLangPt => _config.Language == "pt"; - public bool IsLangDe => _config.Language == "de"; - public bool IsUnitPlm => _config.Unit == "PLM"; - public bool IsUnitMilli => _config.Unit == "mPLM"; - public bool IsUnitMicro => _config.Unit == "µPLM"; - public bool IsUnitSat => _config.Unit == "sat"; + // ---- stato pannelli ---- - [RelayCommand] - private void SetLanguage(string language) - { - _config.Language = language; - ApplySettings(_config); - } + public string[] Networks { get; } = ["mainnet", "testnet", "regtest"]; - [RelayCommand] - private void SetUnit(string unit) - { - _config.Unit = unit; - ApplySettings(_config); - } + [ObservableProperty] + private string selectedNetwork = "mainnet"; - /// Applica e persiste le impostazioni (§8). - public void ApplySettings(AppConfig config) - { - _config = config; - _config.Save(); - _loc = Loc.SwitchTo(config.Language); - OnPropertyChanged(nameof(Loc)); - OnPropertyChanged(nameof(UnitLabel)); - OnPropertyChanged(nameof(IsLangIt)); - OnPropertyChanged(nameof(IsLangEn)); - OnPropertyChanged(nameof(IsLangEs)); - OnPropertyChanged(nameof(IsLangFr)); - OnPropertyChanged(nameof(IsLangPt)); - OnPropertyChanged(nameof(IsLangDe)); - OnPropertyChanged(nameof(IsUnitPlm)); - OnPropertyChanged(nameof(IsUnitMilli)); - OnPropertyChanged(nameof(IsUnitMicro)); - OnPropertyChanged(nameof(IsUnitSat)); - ApplyCache(_doc?.Cache); - StatusMessage = Loc.Tr("msg.settings.saved"); - } + partial void OnSelectedNetworkChanged(string value) => RefreshSetupState(); + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsSetupVisible))] + private bool isWalletOpen; + + public bool IsSetupVisible => !IsWalletOpen; + + [ObservableProperty] + private string statusMessage = ""; + + // ---- collections per la dashboard ---- + + public ObservableCollection History { get; } = []; + public ObservableCollection Addresses { get; } = []; + + // ---- helpers ---- + + private NetKind Net => System.Enum.Parse(SelectedNetwork, ignoreCase: true); + private ChainProfile Profile => ChainProfiles.For(Net); + private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net)); - /// Formatta un importo nell'unità scelta nelle impostazioni. private string Fmt(long sats, bool withLabel = true) => CoinAmount.FormatIn(sats, _config.Unit, withLabel); - /// Chiave privata WIF per un indirizzo; stringa vuota se watch-only. private string KeyWif(bool isChange, int index) { if (_account is null or { IsWatchOnly: true }) return ""; @@ -157,451 +140,28 @@ public partial class MainWindowViewModel : ViewModelBase catch { return ""; } } - /// File in attesa di password (apertura da menu File → Apri). - private string? _pendingOpenPath; - - /// Dopo la prima connessione riuscita il timer riconnette da solo. - private bool _autoReconnect; - - private readonly DispatcherTimer _keepAliveTimer; - - // ---- selezione rete e stato pannelli ---- - - public string[] Networks { get; } = ["mainnet", "testnet", "regtest"]; - - [ObservableProperty] - private string selectedNetwork = "mainnet"; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(IsSetupVisible))] - private bool isWalletOpen; - - public bool IsSetupVisible => !IsWalletOpen; - - [ObservableProperty] - private bool walletFileExists; - - [ObservableProperty] - private string statusMessage = ""; - - // ---- wizard di setup (§15): un passo alla volta ---- - - public const string StepDataLocation = "data-location"; - public const string StepStart = "start"; - public const string StepChooseWallet = "choose-wallet"; - public const string StepOpen = "open"; - public const string StepShowSeed = "show-seed"; - public const string StepConfirmSeed = "confirm-seed"; - public const string StepWords = "words"; - public const string StepPassphrase = "passphrase"; - public const string StepPassword = "password"; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(IsStepDataLocation))] - [NotifyPropertyChangedFor(nameof(IsStepStart))] - [NotifyPropertyChangedFor(nameof(IsStepChooseWallet))] - [NotifyPropertyChangedFor(nameof(IsStepOpen))] - [NotifyPropertyChangedFor(nameof(IsStepShowSeed))] - [NotifyPropertyChangedFor(nameof(IsStepConfirmSeed))] - [NotifyPropertyChangedFor(nameof(IsStepWords))] - [NotifyPropertyChangedFor(nameof(IsStepPassphrase))] - [NotifyPropertyChangedFor(nameof(IsStepPassword))] - private string setupStep = StepStart; - - public bool IsStepDataLocation => SetupStep == StepDataLocation; - public bool IsStepStart => SetupStep == StepStart; - public bool IsStepChooseWallet => SetupStep == StepChooseWallet; - public bool IsStepOpen => SetupStep == StepOpen; - public bool IsStepShowSeed => SetupStep == StepShowSeed; - public bool IsStepConfirmSeed => SetupStep == StepConfirmSeed; - public bool IsStepWords => SetupStep == StepWords; - public bool IsStepPassphrase => SetupStep == StepPassphrase; - public bool IsStepPassword => SetupStep == StepPassword; - - /// Percorso dati predefinito, mostrato al primo avvio. - public string DefaultDataPath => AppPaths.DefaultDataRoot(); - - /// True quando il flusso è "ripristina" (parole inserite dall'utente). - private bool _isRestoreFlow; - - [ObservableProperty] - private string mnemonicInput = ""; - - [ObservableProperty] - private string confirmMnemonicInput = ""; - - [ObservableProperty] - private string passphraseInput = ""; - - [ObservableProperty] - private string passwordInput = ""; - - /// Conferma password alla creazione (stile Electrum: digitata due volte). - [ObservableProperty] - private string confirmPasswordInput = ""; - - /// Se true il file wallet viene cifrato con la password (default, stile Electrum). - [ObservableProperty] - private bool encryptWallet = true; - - // ---- pannello wallet ---- - - [ObservableProperty] - private string balanceText = "—"; - - [ObservableProperty] - private string unconfirmedText = ""; - - [ObservableProperty] - private string networkInfo = ""; - - [ObservableProperty] - private string receiveAddress = ""; - - /// QR code dell'indirizzo di ricezione corrente (PNG in-memory). - [ObservableProperty] - private Bitmap? receiveQr; - - // Rigenera il QR ogni volta che l'indirizzo di ricezione cambia. - partial void OnReceiveAddressChanged(string value) - { - var previous = ReceiveQr; - ReceiveQr = string.IsNullOrEmpty(value) ? null : GenerateQr(value); - previous?.Dispose(); - } - - /// Feedback dopo la copia dell'indirizzo negli appunti (chiamato dal code-behind). - public void NotifyAddressCopied() => StatusMessage = Loc.Tr("addr.copied"); - - private static Bitmap? GenerateQr(string text) - { - try - { - using var generator = new QRCodeGenerator(); - using var data = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.M); - var png = new PngByteQRCode(data).GetGraphic(8); - return new Bitmap(new MemoryStream(png)); - } - catch - { - return null; - } - } - - [ObservableProperty] - private string serverHost = ""; - - [ObservableProperty] - private string serverPort = ""; - - /// Si predilige TLS: la connessione automatica all'apertura del - /// wallet usa la porta SSL del server. L'utente può disattivarlo. - [ObservableProperty] - private bool useSsl = true; - - /// Overlay impostazioni server: aperto dall'overlay Impostazioni. - [ObservableProperty] - private bool isServerSettingsOpen; - - [RelayCommand] - private void OpenServerSettings() - { - IsSettingsOpen = false; - IsServerSettingsOpen = true; - } - - [RelayCommand] - private void CloseServerSettings() => IsServerSettingsOpen = false; - - /// Overlay impostazioni (lingua, unità, server): in-app per evitare - /// i popup di menu annidati, lenti su WSLg. - [ObservableProperty] - private bool isSettingsOpen; - - [RelayCommand] - private void OpenSettings() => IsSettingsOpen = true; - - [RelayCommand] - private void CloseSettings() => IsSettingsOpen = false; - - /// Overlay informazioni sul software (menu Help). - [ObservableProperty] - private bool isHelpOpen; - - [RelayCommand] - private void OpenHelp() => IsHelpOpen = true; - - [RelayCommand] - private void CloseHelp() => IsHelpOpen = false; - - [ObservableProperty] - private string connectionStatus = Loc.Tr("conn.none"); - - [ObservableProperty] - private bool isConnected; - - [ObservableProperty] - private bool isSyncing; - - public ObservableCollection KnownServers { get; } = []; - - [ObservableProperty] - private KnownServer? selectedKnownServer; - - public ObservableCollection History { get; } = []; - - public ObservableCollection Addresses { get; } = []; - - /// Wallet disponibili nella cartella della rete, per la schermata di scelta. - public ObservableCollection WalletList { get; } = []; - - // ---- tab indirizzi ---- - - [ObservableProperty] - private AddressRow? selectedAddressRow; - - /// Dettaglio indirizzo mostrato nell'overlay in-app; null = nascosto. - [ObservableProperty] - private AddressInfo? addressInfo; - - /// Apre l'overlay con i dati dell'indirizzo passato. - public void ShowAddressInfo(AddressRow row) => - AddressInfo = new AddressInfo(Loc, row.Indirizzo, row.DerivPath, row.PubKey, row.PrivKey); - - [RelayCommand] - private void CloseAddressInfo() => AddressInfo = null; - - // ---- overlay dettaglio transazione ---- - - /// Overlay dettaglio transazione aperto. - [ObservableProperty] - private bool isTxDetailsOpen; - - /// Caricamento dei dati in corso (mostra lo spinner nell'overlay). - [ObservableProperty] - private bool isTxDetailsLoading; - - /// Dati della transazione mostrata; null finché non sono pronti. - [ObservableProperty] - private TransactionDetailsViewModel? txDetails; - - private CancellationTokenSource? _txDetailsCts; - - /// - /// Apre l'overlay dettaglio transazione: appare subito con lo spinner e i - /// dati arrivano dal server in background. Overlay in-app (come indirizzo e - /// impostazioni) per apertura/chiusura istantanee, senza una top-level window. - /// - public async Task ShowTransactionDetailsAsync(string txid) - { - if (_client is null || !_client.IsConnected) - { - StatusMessage = Loc.Tr("tx.needconnection"); - return; - } - - _txDetailsCts?.Cancel(); - _txDetailsCts = new CancellationTokenSource(); - var ct = _txDetailsCts.Token; - - TxDetails = null; - IsTxDetailsLoading = true; - IsTxDetailsOpen = true; - - var details = await BuildTransactionDetailsAsync(txid, ct); - - // L'utente potrebbe aver chiuso l'overlay (o aperto un'altra tx) mentre - // caricava: non sovrascrivere lo stato in quel caso. - if (ct.IsCancellationRequested || !IsTxDetailsOpen) - return; - - if (details is null) - { - IsTxDetailsOpen = false; - IsTxDetailsLoading = false; - return; - } - - TxDetails = details; - IsTxDetailsLoading = false; - } - - [RelayCommand] - private void CloseTransactionDetails() - { - _txDetailsCts?.Cancel(); - IsTxDetailsOpen = false; - IsTxDetailsLoading = false; - TxDetails = null; - } - - /// - /// Recupera dal server tutti i dati della transazione - /// (importi, fee, indirizzi, dimensioni, data) e li impacchetta per la - /// finestra di dettaglio. Restituisce null se non c'è connessione o in errore. - /// - public async Task BuildTransactionDetailsAsync( - string txid, CancellationToken ct = default) - { - if (_client is null || !_client.IsConnected) - { - StatusMessage = Loc.Tr("tx.needconnection"); - return null; - } - if (_doc?.Cache is not { } cache) - return null; - - // Snapshot dei dati sull'UI thread, poi tutto il lavoro (round-trip al - // server + parsing delle transazioni) va su un thread pool: altrimenti i - // continuation dei fetch riprendono sull'UI thread e bloccano la finestra - // (spinner fermo, chiusura ritardata). - var client = _client; - var network = PalladiumNetworks.For(Net); - var row = cache.History.FirstOrDefault(t => t.Txid == txid); - var owned = cache.Addresses.Select(a => a.Address).ToHashSet(); - var tipHeight = cache.TipHeight; - var height = row?.Height ?? 0; - var delta = row?.DeltaSats ?? 0; - var verified = row?.Verified ?? false; - var transactions = _lastTransactions; - var loc = _loc; - var unit = _config.Unit; - - try - { - return await Task.Run(async () => - { - var details = await TransactionInspector.FetchAsync( - client, network, txid, tipHeight, height, owned, delta, verified, transactions, ct); - return new TransactionDetailsViewModel(details, loc, unit); - }, ct); - } - catch (OperationCanceledException) - { - return null; - } - catch (Exception ex) - { - StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}"; - return null; - } - } - - // ---- rubrica contatti ---- - - public ObservableCollection Contacts { get; } = []; - - [ObservableProperty] - private ContactEntry? selectedContactInList; - - /// Contatto selezionato nella ComboBox del pannello Invia: riempie SendTo. - [ObservableProperty] - private ContactEntry? sendToContact; - - partial void OnSendToContactChanged(ContactEntry? value) - { - if (value is not null) - SendTo = value.Address; - } - - [ObservableProperty] - private string newContactName = ""; - - [ObservableProperty] - private string newContactAddress = ""; - - [RelayCommand] - private void AddContact() - { - var name = NewContactName.Trim(); - var addr = NewContactAddress.Trim(); - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(addr)) return; - Contacts.Add(new ContactEntry(name, addr)); - NewContactName = NewContactAddress = ""; - PersistContacts(); - } - - [RelayCommand] - private void RemoveSelectedContact() - { - if (SelectedContactInList is { } c) - { - Contacts.Remove(c); - SelectedContactInList = null; - PersistContacts(); - } - } - - private void PersistContacts() - { - if (_doc is null || _walletPath is null) return; - _doc.Contacts = Contacts - .Select(c => new PalladiumWallet.Core.Storage.StoredContact { Name = c.Name, Address = c.Address }) - .ToList(); - WalletStore.Save(_doc, _walletPath, _password); - } - - private void LoadContacts() - { - Contacts.Clear(); - if (_doc is null) return; - foreach (var c in _doc.Contacts) - Contacts.Add(new ContactEntry(c.Name, c.Address)); - } - - // ---- pannello invia ---- - - [ObservableProperty] - private string sendTo = ""; - - [ObservableProperty] - private string sendAmount = ""; - - [ObservableProperty] - private string sendFeeRate = "1"; - - [ObservableProperty] - private bool sendAll; - - [ObservableProperty] - private string sendPreview = ""; - - [ObservableProperty] - private bool hasPendingSend; + // ---- costruttore ---- public MainWindowViewModel() { _loc = Loc.SwitchTo(_config.Language); - // Primo avvio: se la posizione dei dati non è ancora determinata, - // chiediamola prima di tutto il resto del wizard. if (!AppPaths.IsDataLocationConfigured()) SetupStep = StepDataLocation; else RefreshSetupState(); - // Aggiornamenti continui (§9): ping periodico per tenere viva la - // connessione e accorgersi subito della caduta; se cade, riconnette - // e risincronizza da solo. Le notifiche push restano la via principale. - _keepAliveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(20) }; + _keepAliveTimer = new DispatcherTimer { Interval = System.TimeSpan.FromSeconds(20) }; _keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync(); _keepAliveTimer.Start(); } - private async Task KeepAliveTickAsync() + private async System.Threading.Tasks.Task KeepAliveTickAsync() { - // Vale anche senza wallet aperto: se l'utente si è connesso dalle - // impostazioni server, la connessione va mantenuta e riconnessa. if (IsSyncing) return; if (_client is { IsConnected: true }) { - try - { - await _client.PingAsync(); - } - catch - { - // La caduta viene gestita dall'evento Disconnected. - } + try { await _client.PingAsync(); } + catch { } } else if (_autoReconnect) { @@ -610,669 +170,7 @@ public partial class MainWindowViewModel : ViewModelBase } } - private NetKind Net => Enum.Parse(SelectedNetwork, ignoreCase: true); - private ChainProfile Profile => ChainProfiles.For(Net); - - partial void OnSelectedNetworkChanged(string value) => RefreshSetupState(); - - private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net)); - - /// Primo avvio: usa il percorso dati predefinito di piattaforma. - [RelayCommand] - private void UseDefaultDataLocation() => ApplyDataLocation(AppPaths.DefaultDataRoot()); - - /// - /// Memorizza la radice dati scelta (default o personalizzata), ricarica la - /// configurazione dalla nuova posizione e prosegue col wizard. Chiamata anche - /// dalla View dopo la scelta di una cartella. - /// - public void ApplyDataLocation(string root) - { - try - { - AppPaths.ConfigureDataLocation(root); - } - catch (Exception ex) - { - StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}"; - return; - } - // La config globale vive nella radice dati: ricaricala da lì. - _config = AppConfig.Load(); - _loc = Loc.SwitchTo(_config.Language); - OnPropertyChanged(nameof(Loc)); - OnPropertyChanged(nameof(UnitLabel)); - RefreshSetupState(); - } - - private void RefreshSetupState() - { - SetupStep = StepStart; - MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = ""; - WalletFileExists = AppPaths.WalletFiles(Net).Count > 0; - StatusMessage = ""; - RefreshServers(); - // Come Electrum: ci si connette al server già durante il setup, senza - // aspettare l'apertura di un wallet (la sync parte poi all'apertura). - _ = ConnectAndSync(); - } - - private void RefreshServers() - { - KnownServers.Clear(); - foreach (var server in Registry.All) - KnownServers.Add(server); - SelectedKnownServer = KnownServers.FirstOrDefault(); - if (SelectedKnownServer is null) - { - ServerHost = "127.0.0.1"; - ServerPort = (UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort).ToString(); - } - } - - /// Evita la ricorsione tra OnUseSslChanged e OnServerPortChanged - /// mentre tengono coerenti porta e flag TLS. - private bool _syncingServerFields; - - partial void OnSelectedKnownServerChanged(KnownServer? value) - { - if (value is null) - return; - _syncingServerFields = true; - ServerHost = value.Host; - ServerPort = value.PortFor(UseSsl).ToString(); - _syncingServerFields = false; - } - - partial void OnUseSslChanged(bool value) - { - if (_syncingServerFields) - return; - // Attivare/disattivare TLS allinea la porta: porta SSL del server - // selezionato (o default di profilo) quando TLS è on, porta TCP quando off. - _syncingServerFields = true; - ServerPort = SelectedKnownServer is { } server - ? server.PortFor(value).ToString() - : (value ? Profile.DefaultSslPort : Profile.DefaultTcpPort).ToString(); - _syncingServerFields = false; - } - - partial void OnServerPortChanged(string value) - { - if (_syncingServerFields) - return; - if (!int.TryParse(value.Trim(), out var port)) - return; - // Scegliere una porta nota allinea il flag TLS, così non si tenta mai - // una connessione in chiaro sulla porta SSL (causa di "connection error"). - bool? wantSsl = - SelectedKnownServer is { } s && port == s.SslPort ? true : - SelectedKnownServer is { } t && port == t.TcpPort ? false : - port == Profile.DefaultSslPort ? true : - port == Profile.DefaultTcpPort ? false : - null; - if (wantSsl is bool b && b != UseSsl) - { - _syncingServerFields = true; - UseSsl = b; - _syncingServerFields = false; - } - } - - // ---------- comandi del wizard (§15): un passo alla volta ---------- - - [RelayCommand] - private void WizardStartOpen() - { - // Con più wallet nella cartella della rete si sceglie quale aprire; - // con uno solo si va dritti alla password (multi-wallet §8). - var files = AppPaths.WalletFiles(Net); - if (files.Count > 1) - { - WalletList.Clear(); - foreach (var path in files) - WalletList.Add(new WalletFileEntry(Path.GetFileName(path), path)); - SetupStep = StepChooseWallet; - StatusMessage = ""; - return; - } - _pendingOpenPath = files.Count == 1 ? files[0] : AppPaths.DefaultWalletPath(Net); - PasswordInput = ""; - SetupStep = StepOpen; - StatusMessage = ""; - } - - /// Sceglie quale wallet aprire dalla lista e passa alla password. - [RelayCommand] - private void ChooseWallet(WalletFileEntry? entry) - { - if (entry is null) - return; - _pendingOpenPath = entry.Path; - PasswordInput = ""; - SetupStep = StepOpen; - StatusMessage = ""; - } - - [RelayCommand] - private void WizardStartNew() - { - _isRestoreFlow = false; - MnemonicInput = Bip39.Generate(MnemonicLength.Twelve).ToString(); - SetupStep = StepShowSeed; - StatusMessage = ""; - } - - [RelayCommand] - private void WizardStartRestore() - { - _isRestoreFlow = true; - MnemonicInput = ""; - SetupStep = StepWords; - StatusMessage = ""; - } - - [RelayCommand] - private void WizardNextFromShowSeed() - { - ConfirmMnemonicInput = ""; - SetupStep = StepConfirmSeed; - StatusMessage = ""; - } - - [RelayCommand] - private void WizardNextFromConfirmSeed() - { - var normalized = string.Join(' ', - ConfirmMnemonicInput.Split(' ', StringSplitOptions.RemoveEmptyEntries)); - if (!string.Equals(normalized, MnemonicInput, StringComparison.OrdinalIgnoreCase)) - { - StatusMessage = Loc.Tr("msg.seed.mismatch"); - return; - } - GoToPassphraseStep(); - } - - [RelayCommand] - private void WizardNextFromWords() - { - if (!Bip39.TryParse(MnemonicInput, out _)) - { - StatusMessage = Loc.Tr("msg.words.invalid"); - return; - } - GoToPassphraseStep(); - } - - private void GoToPassphraseStep() - { - PassphraseInput = ""; - SetupStep = StepPassphrase; - StatusMessage = ""; - } - - [RelayCommand] - private void WizardNextFromPassphrase() - { - PasswordInput = ConfirmPasswordInput = ""; - EncryptWallet = true; - SetupStep = StepPassword; - StatusMessage = ""; - } - - [RelayCommand] - private void WizardBack() - { - SetupStep = SetupStep switch - { - StepOpen => WalletList.Count > 1 ? StepChooseWallet : StepStart, - StepChooseWallet or StepShowSeed or StepWords => StepStart, - StepConfirmSeed => StepShowSeed, - StepPassphrase => _isRestoreFlow ? StepWords : StepConfirmSeed, - StepPassword => StepPassphrase, - _ => StepStart, - }; - if (SetupStep == StepStart) - RefreshSetupState(); - } - - [RelayCommand] - private void CreateOrRestore() - { - // Scelta cifratura stile Electrum: con cifratura attiva serve una - // password non vuota, digitata due volte e coincidente. Solo se la - // cifratura è disattivata il file resta in chiaro (avviso esplicito). - string? password; - if (EncryptWallet) - { - if (string.IsNullOrEmpty(PasswordInput)) - { - StatusMessage = Loc.Tr("msg.password.required"); - return; - } - if (PasswordInput != ConfirmPasswordInput) - { - StatusMessage = Loc.Tr("msg.password.mismatch"); - return; - } - password = PasswordInput; - } - else - { - password = null; - } - - try - { - var (doc, account) = WalletLoader.NewFromMnemonic( - MnemonicInput, - string.IsNullOrEmpty(PassphraseInput) ? null : PassphraseInput, - ScriptKind.NativeSegwit, - Profile); - // Mai sovrascrivere un wallet esistente: si cerca il primo nome libero. - var path = AppPaths.DefaultWalletPath(Net); - for (var n = 2; WalletStore.Exists(path); n++) - path = Path.Combine(AppPaths.WalletsDir(Net), $"wallet-{n}.wallet.json"); - WalletStore.Save(doc, path, password); - var newLock = WalletLock.TryAcquire(path); - if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; } - OpenLoaded(doc, account, path, password, newLock); - } - catch (Exception ex) - { - StatusMessage = $"Errore: {ex.Message}"; - } - } - - [RelayCommand] - private void OpenExisting() - { - var path = _pendingOpenPath ?? AppPaths.DefaultWalletPath(Net); - var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput; - - // Il lock viene acquisito prima del caricamento: se il wallet è già aperto - // altrove, non ha senso nemmeno tentare la decifrazione. - var newLock = WalletLock.TryAcquire(path); - if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; } - - try - { - var doc = WalletStore.Load(path, password); - _pendingOpenPath = null; - OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password, newLock); - } - catch (WrongPasswordException) - { - newLock.Dispose(); - StatusMessage = Loc.Tr("msg.wrongpassword"); - } - catch (UnauthorizedAccessException) - { - newLock.Dispose(); - StatusMessage = Loc.Tr("msg.wallet.noaccess"); - } - catch (Exception ex) - { - newLock.Dispose(); - StatusMessage = $"Errore: {ex.Message}"; - } - } - - /// Apertura di un file wallet qualunque (menu File → Apri, multi-wallet §8). - public void OpenFromPath(string path) - { - // Il lock viene acquisito prima di chiudere l'eventuale wallet aperto: - // se il nuovo wallet non è disponibile, la sessione corrente resta intatta. - var newLock = WalletLock.TryAcquire(path); - if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; } - - try - { - var doc = WalletStore.Load(path); - if (IsWalletOpen) - CloseWallet(); - OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password: null, newLock); - } - catch (WrongPasswordException) - { - // Cifrato: si chiede la password nel passo di apertura del wizard. - newLock.Dispose(); - if (IsWalletOpen) - CloseWallet(); - _pendingOpenPath = path; - WalletFileExists = true; - PasswordInput = ""; - SetupStep = StepOpen; - StatusMessage = ""; - } - catch (UnauthorizedAccessException) - { - newLock.Dispose(); - StatusMessage = Loc.Tr("msg.wallet.noaccess"); - } - catch (Exception ex) - { - newLock.Dispose(); - StatusMessage = $"Errore: {ex.Message}"; - } - } - - /// Torna al pannello di setup per creare/ripristinare un altro wallet. - [RelayCommand] - private void NewWallet() - { - if (IsWalletOpen) - CloseWallet(); - _pendingOpenPath = null; - StatusMessage = ""; - } - - private void OpenLoaded(WalletDocument doc, HdAccount account, string path, string? password, WalletLock walletLock) - { - _walletLock?.Dispose(); - _walletLock = walletLock; - - // La rete del wallet comanda (registry, pin TLS, indirizzi). - SelectedNetwork = doc.Network; - _doc = doc; - _account = account; - _walletPath = path; - _password = password; - MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = ""; - SetupStep = StepStart; - - NetworkInfo = $"{doc.Network} · {doc.ScriptKind} · m/{doc.AccountPath}" - + (doc.IsWatchOnly ? " · watch-only" : ""); - LoadContacts(); - ApplyCache(doc.Cache); - IsWalletOpen = true; - StatusMessage = Loc.Tr("msg.opened"); - // Come Electrum: ci si connette da soli al server selezionato, - // senza aspettare un click. - _ = ConnectAndSync(); - } - - private void ApplyCache(SyncCache? cache) - { - if (_account is null) - return; - if (cache is null) - { - BalanceText = $"0.00000000 {Profile.CoinUnit}"; - UnconfirmedText = ""; - ReceiveAddress = _account.GetReceiveAddress(0).ToString(); - History.Clear(); - // Prima della sincronizzazione si mostrano i primi indirizzi derivati. - Addresses.Clear(); - for (var i = 0; i < 10; i++) - Addresses.Add(new AddressRow(_loc["addr.receive"], i, - _account.GetReceiveAddress(i).ToString(), "—", "—", - false, - _account.GetPublicKey(false, i).ToHex(), - KeyWif(false, i), - $"m/{_doc!.AccountPath}/0/{i}")); - return; - } - BalanceText = Fmt(cache.ConfirmedSats); - // Saldo in attesa: somma delle tx in mempool (può essere negativo per - // gli invii in uscita non ancora confermati). Non è spendibile finché - // non conferma: la TransactionFactory usa solo UTXO confermati. - var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats); - UnconfirmedText = pending != 0 - ? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}" - : ""; - ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString(); - History.Clear(); - foreach (var tx in cache.History) - History.Add(new HistoryRow( - tx.Height > 0 ? tx.Height.ToString() : "mempool", - (tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false), - tx.Txid, - tx.Verified ? "✓ SPV" : "—")); - - Addresses.Clear(); - foreach (var a in cache.Addresses) - Addresses.Add(new AddressRow( - a.IsChange ? _loc["addr.change"] : _loc["addr.receive"], - a.Index, - a.Address, - a.BalanceSats > 0 ? Fmt(a.BalanceSats, withLabel: false) : (a.TxCount > 0 ? "0" : "—"), - a.TxCount > 0 ? a.TxCount.ToString() : "—", - a.IsChange, - _account.GetPublicKey(a.IsChange, a.Index).ToHex(), - KeyWif(a.IsChange, a.Index), - $"m/{_doc!.AccountPath}/{(a.IsChange ? 1 : 0)}/{a.Index}")); - } - - // ---------- comandi wallet ---------- - - [RelayCommand] - private async Task ConnectAndSync() - { - if (IsSyncing) - { - _resyncRequested = true; - return; - } - IsSyncing = true; - StatusMessage = ""; - try - { - var (host, port) = ParseServer(); - - // Se l'endpoint richiesto (host/porta/TLS) è diverso da quello della - // connessione attiva, la chiudo: l'utente ha cambiato server o ha - // attivato TLS e si aspetta che la nuova scelta abbia effetto. - if (_client is { } current && - (current.Host != host || current.Port != port || current.UseSsl != UseSsl)) - { - await DisconnectAsync(); - } - - if (_client is null || !_client.IsConnected) - { - ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {host}:{port}…"; - var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net)); - _client = await ElectrumClient.ConnectAsync(host, port, UseSsl, pins); - _client.NotificationReceived += OnServerNotification; - _client.Disconnected += _ => Dispatcher.UIThread.Post(() => - { - IsConnected = false; - ConnectionStatus = Loc.Tr("conn.disconnected"); - }); - IsConnected = true; - _autoReconnect = true; - ConnectionStatus = $"{Loc.Tr("conn.connectedto")} {host}:{port}{(UseSsl ? " (TLS)" : "")}"; - } - - // Senza un wallet aperto ci si limita a stabilire/verificare la - // connessione: la sincronizzazione richiede un account. - if (_account is null || _doc is null) - return; - - // Synchronizer legato all'account corrente, creato alla prima sync - // dopo la connessione: conserva la cache di tx e prove verificate, - // così le risincronizzazioni sono incrementali. - if (_synchronizer is null) - { - _synchronizer = new WalletSynchronizer(_account, _client, _doc.GapLimit); - _synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg); - } - - // Se durante la sync arrivano notifiche, si ripete subito: nessun - // aggiornamento del server va perso (modello Electrum). - do - { - _resyncRequested = false; - var result = await _synchronizer.SyncOnceAsync(); - _lastTransactions = result.Transactions; - - _doc.Cache = new SyncCache - { - TipHeight = result.TipHeight, - ConfirmedSats = result.ConfirmedSats, - UnconfirmedSats = result.UnconfirmedSats, - NextReceiveIndex = result.NextReceiveIndex, - NextChangeIndex = result.NextChangeIndex, - History = [.. result.History], - Utxos = [.. result.Utxos], - Addresses = [.. result.AddressRows], - }; - WalletStore.Save(_doc, _walletPath!, _password); - ApplyCache(_doc.Cache); - StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " + - $"{result.History.Count} {Loc.Tr("msg.synced.detail")}"; - } while (_resyncRequested); - } - catch (CertificatePinMismatchException ex) - { - IsConnected = false; - ConnectionStatus = Loc.Tr("conn.certchanged"); - StatusMessage = ex.Message; - } - catch (Exception ex) - { - IsConnected = _client?.IsConnected == true; - ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.error"); - StatusMessage = $"Errore: {ex.Message}"; - } - finally - { - IsSyncing = false; - } - } - - /// Scopre nuovi server dai peer annunciati dal server connesso (§9). - [RelayCommand] - private async Task DiscoverServers() - { - if (_client is null || !_client.IsConnected) - { - StatusMessage = Loc.Tr("conn.none") + "."; - return; - } - try - { - var added = await Registry.DiscoverAsync(_client); - var selected = SelectedKnownServer; - RefreshServers(); - SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == selected?.Host) - ?? KnownServers.FirstOrDefault(); - StatusMessage = added > 0 - ? $"Trovati {added} nuovi server dai peer (totale {KnownServers.Count})." - : $"Nessun nuovo server annunciato (totale {KnownServers.Count})."; - } - catch (Exception ex) - { - StatusMessage = $"Errore nella scoperta peer: {ex.Message}"; - } - } - - private void OnServerNotification(string method, System.Text.Json.JsonElement payload) - { - // Cambiamento su un nostro indirizzo o nuovo blocco: risincronizza. - // Se una sync è già in corso, si accoda (il loop in ConnectAndSync la - // ripete subito dopo): nessuna notifica viene persa. - if (method is "blockchain.scripthash.subscribe" or "blockchain.headers.subscribe") - Dispatcher.UIThread.Post(() => - { - if (IsSyncing) - _resyncRequested = true; - else - _ = ConnectAndSync(); - }); - } - - [RelayCommand] - private void ResetCertificates() - { - new CertificatePinStore(AppPaths.CertificatePinsPath(Net)).ResetAll(); - StatusMessage = Loc.Tr("msg.certreset"); - } - - [RelayCommand] - private async Task PrepareSend() - { - if (_account is null || _doc?.Cache is null) - { - SendPreview = "Sincronizza prima di inviare."; - return; - } - try - { - if (_lastTransactions is null) - { - SendPreview = "Connettiti al server e sincronizza prima di inviare."; - return; - } - - var destination = BitcoinAddress.Create(SendTo.Trim(), PalladiumNetworks.For(Net)); - long amount = 0; - if (!SendAll && !CoinAmount.TryParseIn(SendAmount, _config.Unit, out amount)) - { - SendPreview = "Importo non valido."; - return; - } - if (!decimal.TryParse(SendFeeRate, out var feeRate) || feeRate <= 0) - { - SendPreview = "Fee rate non valido."; - return; - } - - _pendingSend = new TransactionFactory(_account).Build( - _doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate, - _doc.Cache.NextChangeIndex, SendAll); - - SendPreview = $"txid {_pendingSend.Txid[..16]}… · " + - $"fee {Fmt(_pendingSend.Fee.Satoshi)} " + - $"({_pendingSend.Transaction.GetVirtualSize()} vB)" + - (_pendingSend.Signed ? "" : " · NON firmata (watch-only)"); - HasPendingSend = _pendingSend.Signed; - } - catch (Exception ex) - { - _pendingSend = null; - HasPendingSend = false; - SendPreview = $"Errore: {ex.Message}"; - } - await Task.CompletedTask; - } - - [RelayCommand] - private async Task ConfirmSend() - { - if (_pendingSend is null || _client is null) - return; - try - { - var txid = await _client.BroadcastAsync(_pendingSend.ToHex()); - SendPreview = $"Trasmessa: {txid}"; - SendTo = SendAmount = ""; - _pendingSend = null; - HasPendingSend = false; - await ConnectAndSync(); - } - catch (Exception ex) - { - SendPreview = $"Errore broadcast: {ex.Message}"; - } - } - - /// - /// Chiude la connessione corrente lasciando il wallet aperto: usata quando - /// l'utente cambia server o attiva TLS e serve riconnettersi al nuovo - /// endpoint. Attende la chiusura del socket prima di tornare. - /// - private async Task DisconnectAsync() - { - if (_client is { } client) - { - _client = null; - _synchronizer = null; - try { await client.DisposeAsync(); } catch { /* in chiusura */ } - } - IsConnected = false; - } + // ---- ciclo di vita wallet ---- [RelayCommand] private void CloseWallet() @@ -1299,13 +197,4 @@ public partial class MainWindowViewModel : ViewModelBase ConnectionStatus = Loc.Tr("conn.none"); RefreshSetupState(); } - - private (string Host, int Port) ParseServer() - { - var host = ServerHost.Trim(); - var port = int.TryParse(ServerPort.Trim(), out var p) - ? p - : UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort; - return (host, port); - } }