feat(app): Avalonia UI shell

This commit is contained in:
2026-06-11 10:47:52 +02:00
parent 3d6597a3b2
commit bb8563580f
10 changed files with 691 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PalladiumWallet.App.App"
xmlns:local="using:PalladiumWallet.App"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
+31
View File
@@ -0,0 +1,31 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using System.Linq;
using Avalonia.Markup.Xaml;
using PalladiumWallet.App.ViewModels;
using PalladiumWallet.App.Views;
namespace PalladiumWallet.App;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
};
}
base.OnFrameworkInitializationCompleted();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

+24
View File
@@ -0,0 +1,24 @@
using Avalonia;
using System;
namespace PalladiumWallet.App;
sealed class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
#if DEBUG
.WithDeveloperTools()
#endif
.WithInterFont()
.LogToTrace();
}
+37
View File
@@ -0,0 +1,37 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using PalladiumWallet.App.ViewModels;
namespace PalladiumWallet.App;
/// <summary>
/// Given a view model, returns the corresponding view if possible.
/// </summary>
[RequiresUnreferencedCode(
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
public class ViewLocator : IDataTemplate
{
public Control? Build(object? param)
{
if (param is null)
return null;
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(name);
if (type != null)
{
return (Control)Activator.CreateInstance(type)!;
}
return new TextBlock { Text = "Not Found: " + name };
}
public bool Match(object? data)
{
return data is ViewModelBase;
}
}
+396
View File
@@ -0,0 +1,396 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Spv;
using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.App.ViewModels;
/// <summary>Riga dello storico transazioni per la vista.</summary>
public sealed record HistoryRow(string Conferma, string Importo, string Txid, string Verificata);
/// <summary>
/// ViewModel unico dell'applicazione (wizard §15 ridotto + dashboard):
/// pannello di setup (crea/ripristina/apri) e pannello wallet
/// (saldo, ricevi, storico, invia, server).
/// </summary>
public partial class MainWindowViewModel : ViewModelBase
{
private WalletDocument? _doc;
private HdAccount? _account;
private string? _walletPath;
private string? _password;
private ElectrumClient? _client;
private IReadOnlyDictionary<string, Transaction>? _lastTransactions;
private BuiltTransaction? _pendingSend;
// ---- 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 = "";
// ---- pannello setup ----
[ObservableProperty]
private string mnemonicInput = "";
[ObservableProperty]
private string passphraseInput = "";
[ObservableProperty]
private string passwordInput = "";
// ---- pannello wallet ----
[ObservableProperty]
private string balanceText = "—";
[ObservableProperty]
private string unconfirmedText = "";
[ObservableProperty]
private string networkInfo = "";
[ObservableProperty]
private string receiveAddress = "";
[ObservableProperty]
private string serverInput = "";
[ObservableProperty]
private bool useSsl;
[ObservableProperty]
private string connectionStatus = "non connesso";
[ObservableProperty]
private bool isSyncing;
public ObservableCollection<HistoryRow> History { get; } = [];
// ---- 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;
public MainWindowViewModel()
{
RefreshSetupState();
}
private NetKind Net => Enum.Parse<NetKind>(SelectedNetwork, ignoreCase: true);
private ChainProfile Profile => ChainProfiles.For(Net);
partial void OnSelectedNetworkChanged(string value) => RefreshSetupState();
private void RefreshSetupState()
{
WalletFileExists = WalletStore.Exists(AppPaths.DefaultWalletPath(Net));
StatusMessage = WalletFileExists
? "Trovato un wallet esistente: inserisci la password (se impostata) e apri."
: "Nessun wallet su questa rete: creane uno nuovo o ripristina da seed.";
ServerInput = $"127.0.0.1:{Profile.DefaultTcpPort}";
}
// ---------- comandi setup ----------
[RelayCommand]
private void GenerateMnemonic()
{
MnemonicInput = Bip39.Generate(MnemonicLength.Twelve).ToString();
StatusMessage = "Nuova mnemonica generata: SCRIVILA SU CARTA prima di continuare.";
}
[RelayCommand]
private void CreateOrRestore()
{
try
{
var (doc, account) = WalletLoader.NewFromMnemonic(
MnemonicInput,
string.IsNullOrEmpty(PassphraseInput) ? null : PassphraseInput,
ScriptKind.NativeSegwit,
Profile);
var path = AppPaths.DefaultWalletPath(Net);
var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput;
WalletStore.Save(doc, path, password);
OpenLoaded(doc, account, path, password);
}
catch (Exception ex)
{
StatusMessage = $"Errore: {ex.Message}";
}
}
[RelayCommand]
private void OpenExisting()
{
try
{
var path = AppPaths.DefaultWalletPath(Net);
var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput;
var doc = WalletStore.Load(path, password);
OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password);
}
catch (WrongPasswordException)
{
StatusMessage = "Password errata.";
}
catch (Exception ex)
{
StatusMessage = $"Errore: {ex.Message}";
}
}
private void OpenLoaded(WalletDocument doc, HdAccount account, string path, string? password)
{
_doc = doc;
_account = account;
_walletPath = path;
_password = password;
MnemonicInput = PassphraseInput = PasswordInput = "";
NetworkInfo = $"{doc.Network} · {doc.ScriptKind} · m/{doc.AccountPath}"
+ (doc.IsWatchOnly ? " · watch-only" : "");
ApplyCache(doc.Cache);
IsWalletOpen = true;
StatusMessage = doc.Cache is null
? "Wallet aperto. Connettiti a un server per sincronizzare."
: "Wallet aperto (dati dell'ultima sincronizzazione).";
}
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();
return;
}
BalanceText = CoinAmount.Format(cache.ConfirmedSats, Profile.CoinUnit);
UnconfirmedText = cache.UnconfirmedSats != 0
? $"+ {CoinAmount.Format(cache.UnconfirmedSats)} non confermato"
: "";
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 ? "+" : "") + CoinAmount.Format(tx.DeltaSats),
tx.Txid,
tx.Verified ? "✓ SPV" : "—"));
}
// ---------- comandi wallet ----------
[RelayCommand]
private async Task ConnectAndSync()
{
if (_account is null || _doc is null)
return;
IsSyncing = true;
StatusMessage = "";
try
{
if (_client is null || !_client.IsConnected)
{
var (host, port) = ParseServer();
ConnectionStatus = $"connessione a {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(() =>
ConnectionStatus = "disconnesso");
ConnectionStatus = $"connesso a {host}:{port}{(UseSsl ? " (TLS)" : "")}";
}
var sync = new WalletSynchronizer(_account, _client, _doc.GapLimit);
sync.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
var result = await sync.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],
};
WalletStore.Save(_doc, _walletPath!, _password);
ApplyCache(_doc.Cache);
StatusMessage = $"Sincronizzato: altezza {result.TipHeight}, " +
$"{result.History.Count} transazioni verificate SPV.";
}
catch (CertificatePinMismatchException ex)
{
ConnectionStatus = "certificato cambiato";
StatusMessage = ex.Message;
}
catch (Exception ex)
{
ConnectionStatus = "errore";
StatusMessage = $"Errore: {ex.Message}";
}
finally
{
IsSyncing = false;
}
}
private void OnServerNotification(string method, System.Text.Json.JsonElement payload)
{
// Cambiamento su un nostro indirizzo o nuovo blocco: risincronizza.
if (method is "blockchain.scripthash.subscribe" or "blockchain.headers.subscribe")
Dispatcher.UIThread.Post(() =>
{
if (!IsSyncing)
_ = ConnectAndSync();
});
}
[RelayCommand]
private void ResetCertificates()
{
new CertificatePinStore(AppPaths.CertificatePinsPath(Net)).ResetAll();
StatusMessage = "Certificati SSL azzerati: riprova la connessione.";
}
[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.TryParseCoins(SendAmount, 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 {CoinAmount.Format(_pendingSend.Fee.Satoshi, Profile.CoinUnit)} " +
$"({_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}";
}
}
[RelayCommand]
private void CloseWallet()
{
_ = _client?.DisposeAsync().AsTask();
_client = null;
_doc = null;
_account = null;
_lastTransactions = null;
_pendingSend = null;
HasPendingSend = false;
History.Clear();
IsWalletOpen = false;
ConnectionStatus = "non connesso";
RefreshSetupState();
}
private (string Host, int Port) ParseServer()
{
var parts = ServerInput.Trim().Split(':');
var host = parts[0];
var port = parts.Length > 1 && int.TryParse(parts[1], out var p)
? p
: UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort;
return (host, port);
}
}
+7
View File
@@ -0,0 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace PalladiumWallet.App.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}
+152
View File
@@ -0,0 +1,152 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PalladiumWallet.App.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="620"
x:Class="PalladiumWallet.App.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/avalonia-logo.ico"
Width="900" Height="620"
Title="Palladium Wallet">
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<Grid RowDefinitions="*,Auto">
<!-- ============ PANNELLO SETUP: crea / ripristina / apri ============ -->
<ScrollViewer Grid.Row="0" IsVisible="{Binding IsSetupVisible}">
<StackPanel MaxWidth="560" Margin="24" Spacing="14">
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="Rete:" VerticalAlignment="Center"/>
<ComboBox ItemsSource="{Binding Networks}"
SelectedItem="{Binding SelectedNetwork}"
MinWidth="140"/>
</StackPanel>
<!-- Apri wallet esistente -->
<Border IsVisible="{Binding WalletFileExists}"
BorderBrush="{DynamicResource SystemAccentColor}" BorderThickness="1"
CornerRadius="6" Padding="14">
<StackPanel Spacing="8">
<TextBlock Text="Apri il wallet esistente" FontWeight="Bold"/>
<TextBox PlaceholderText="Password del file (vuoto se non impostata)"
PasswordChar="●" Text="{Binding PasswordInput}"/>
<Button Content="Apri wallet" Command="{Binding OpenExistingCommand}"/>
</StackPanel>
</Border>
<!-- Crea / ripristina -->
<Border BorderBrush="Gray" BorderThickness="1" CornerRadius="6" Padding="14">
<StackPanel Spacing="8">
<TextBlock Text="Crea nuovo o ripristina da seed" FontWeight="Bold"/>
<TextBox PlaceholderText="Mnemonica BIP39 (12 o 24 parole)"
AcceptsReturn="False" Text="{Binding MnemonicInput}"/>
<Button Content="Genera nuova mnemonica" Command="{Binding GenerateMnemonicCommand}"/>
<TextBox PlaceholderText="Passphrase BIP39 opzionale (cambia il wallet! annotala a parte)"
Text="{Binding PassphraseInput}"/>
<TextBox PlaceholderText="Password di cifratura del file wallet (consigliata)"
PasswordChar="●" Text="{Binding PasswordInput}"/>
<Button Content="Crea / ripristina wallet" Command="{Binding CreateOrRestoreCommand}"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<!-- ============ PANNELLO WALLET ============ -->
<Grid Grid.Row="0" RowDefinitions="Auto,Auto,*" IsVisible="{Binding IsWalletOpen}" Margin="16">
<!-- Testata: saldo + rete + chiudi -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding BalanceText}" FontSize="30" FontWeight="Bold"/>
<TextBlock Text="{Binding UnconfirmedText}" Foreground="Orange"/>
<TextBlock Text="{Binding NetworkInfo}" FontSize="12" Foreground="Gray"/>
</StackPanel>
<Button Grid.Column="1" Content="Chiudi wallet" VerticalAlignment="Top"
Command="{Binding CloseWalletCommand}"/>
</Grid>
<!-- Server -->
<Border Grid.Row="1" Margin="0,12,0,12" Padding="10"
BorderBrush="Gray" BorderThickness="1" CornerRadius="6">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto,Auto">
<TextBlock Grid.Column="0" Text="Server:" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox Grid.Column="1" Text="{Binding ServerInput}"
PlaceholderText="host:porta del server di indicizzazione"/>
<CheckBox Grid.Column="2" Content="TLS" IsChecked="{Binding UseSsl}" Margin="8,0"/>
<Button Grid.Column="3" Content="Connetti e sincronizza"
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"/>
<Button Grid.Column="4" Content="Reset cert." Margin="6,0,0,0"
Command="{Binding ResetCertificatesCommand}"/>
<TextBlock Grid.Column="5" Text="{Binding ConnectionStatus}"
VerticalAlignment="Center" Margin="10,0,0,0" Foreground="Gray"/>
</Grid>
</Border>
<!-- Tab: Ricevi / Storico / Invia -->
<TabControl Grid.Row="2">
<TabItem Header="Ricevi">
<StackPanel Spacing="10" Margin="8">
<TextBlock Text="Prossimo indirizzo non usato:"/>
<SelectableTextBlock Text="{Binding ReceiveAddress}"
FontFamily="monospace" FontSize="16"/>
<TextBlock Text="Ogni pagamento ricevuto qui comparirà nello storico alla prossima sincronizzazione."
Foreground="Gray" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</TabItem>
<TabItem Header="Storico">
<ListBox ItemsSource="{Binding History}" Margin="4">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HistoryRow">
<Grid ColumnDefinitions="90,160,*,70">
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="Gray"/>
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
<SelectableTextBlock Grid.Column="2" Text="{Binding Txid}"
FontFamily="monospace" FontSize="12"/>
<TextBlock Grid.Column="3" Text="{Binding Verificata}" Foreground="Green"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</TabItem>
<TabItem Header="Invia">
<StackPanel Spacing="10" Margin="8" MaxWidth="640" HorizontalAlignment="Left">
<TextBox PlaceholderText="Indirizzo destinatario" Text="{Binding SendTo}"
FontFamily="monospace"/>
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBox Grid.Column="0" PlaceholderText="Importo (es. 1.5)"
Text="{Binding SendAmount}" IsEnabled="{Binding !SendAll}"/>
<CheckBox Grid.Column="1" Content="Invia tutto" Margin="10,0"
IsChecked="{Binding SendAll}"/>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
<TextBlock Text="fee sat/vB:" VerticalAlignment="Center"/>
<TextBox Text="{Binding SendFeeRate}" MinWidth="60"/>
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="Prepara transazione" Command="{Binding PrepareSendCommand}"/>
<Button Content="CONFERMA E TRASMETTI" Classes="accent"
Command="{Binding ConfirmSendCommand}"
IsEnabled="{Binding HasPendingSend}"/>
</StackPanel>
<SelectableTextBlock Text="{Binding SendPreview}" TextWrapping="Wrap"
FontSize="13"/>
</StackPanel>
</TabItem>
</TabControl>
</Grid>
<!-- Barra di stato -->
<Border Grid.Row="1" Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
Padding="10,6">
<TextBlock Text="{Binding StatusMessage}" FontSize="12" TextWrapping="Wrap"/>
</Border>
</Grid>
</Window>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace PalladiumWallet.App.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="PalladiumWallet.App.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>