feat(app): first-run data location wizard step

On first launch (no data yet, no portable dir, no pointer file),
the wizard now shows a new step-0 screen asking where to store
wallets, config and certificates.

AppPaths changes:
- DefaultDataRoot() → ~/.PalladiumWallet (home, always writable)
- IsDataLocationConfigured() → true when portable / override / pointer
  already written / legacy or default already has data
- ConfigureDataLocation(root) writes a bootstrap pointer file and
  creates the directory
- DataRoot() resolution order: override → portable → pointer → legacy
  (has data) → default

ViewModel: new StepDataLocation step, UseDefaultDataLocationCommand,
ApplyDataLocation(root) (also called from View's folder picker).

View: new wizard panel with description, default-path display, two
buttons (use default / choose folder); folder picker via
StorageProvider.OpenFolderPickerAsync.

Loc: add wiz.data.* keys (6 languages); fix fallback language "it"→"en";
update test assertion accordingly.
This commit is contained in:
2026-06-12 09:25:00 +02:00
parent 87e1c82610
commit f3bf4cf94a
6 changed files with 173 additions and 14 deletions
+12 -1
View File
@@ -27,7 +27,7 @@ public sealed class Loc
/// </summary>
internal static Loc SwitchTo(string language)
{
if (System.Array.IndexOf(Languages, language) < 0) language = "it";
if (System.Array.IndexOf(Languages, language) < 0) language = "en";
var loc = new Loc(language);
Instance = loc;
return loc;
@@ -54,6 +54,17 @@ public sealed class Loc
["settings.unit.short"] = ["Unità", "Unit", "Unidad", "Unité", "Unidade", "Einheit"],
// Wizard
["wiz.data.title"] = ["Dove salvare i dati", "Where to store data", "Dónde guardar los datos", "Où enregistrer les données", "Onde salvar os dados", "Wo Daten gespeichert werden"],
["wiz.data.info"] = [
"Scegli la cartella in cui salvare wallet, configurazione e certificati. Puoi usare il percorso predefinito o sceglierne uno tuo.",
"Choose the folder where wallets, configuration and certificates are stored. Use the default path or pick your own.",
"Elige la carpeta donde se guardarán wallets, configuración y certificados. Usa la ruta predeterminada o elige la tuya.",
"Choisissez le dossier où enregistrer les wallets, la configuration et les certificats. Utilisez le chemin par défaut ou le vôtre.",
"Escolha a pasta onde salvar carteiras, configuração e certificados. Use o caminho padrão ou escolha o seu.",
"Wählen Sie den Ordner für Wallets, Konfiguration und Zertifikate. Nutzen Sie den Standardpfad oder einen eigenen."],
["wiz.data.default"] = ["Percorso predefinito:", "Default path:", "Ruta predeterminada:", "Chemin par défaut :", "Caminho padrão:", "Standardpfad:"],
["wiz.data.usedefault"] = ["Usa il percorso predefinito", "Use the default path", "Usar la ruta predeterminada", "Utiliser le chemin par défaut", "Usar o caminho padrão", "Standardpfad verwenden"],
["wiz.data.choose"] = ["Scegli una cartella…", "Choose a folder…", "Elegir una carpeta…", "Choisir un dossier…", "Escolher uma pasta…", "Ordner wählen…"],
["wiz.net"] = ["Rete:", "Network:", "Red:", "Réseau :", "Rede:", "Netzwerk:"],
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet", "Abrir wallet existente", "Ouvrir le wallet existant", "Abrir carteira existente", "Vorhandenes Wallet öffnen"],
["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet", "Crear nuevo wallet", "Créer un nouveau wallet", "Criar nova carteira", "Neues Wallet erstellen"],
+39
View File
@@ -165,6 +165,7 @@ public partial class MainWindowViewModel : ViewModelBase
// ---- wizard di setup (§15): un passo alla volta ----
public const string StepDataLocation = "data-location";
public const string StepStart = "start";
public const string StepOpen = "open";
public const string StepShowSeed = "show-seed";
@@ -174,6 +175,7 @@ public partial class MainWindowViewModel : ViewModelBase
public const string StepPassword = "password";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsStepDataLocation))]
[NotifyPropertyChangedFor(nameof(IsStepStart))]
[NotifyPropertyChangedFor(nameof(IsStepOpen))]
[NotifyPropertyChangedFor(nameof(IsStepShowSeed))]
@@ -183,6 +185,7 @@ public partial class MainWindowViewModel : ViewModelBase
[NotifyPropertyChangedFor(nameof(IsStepPassword))]
private string setupStep = StepStart;
public bool IsStepDataLocation => SetupStep == StepDataLocation;
public bool IsStepStart => SetupStep == StepStart;
public bool IsStepOpen => SetupStep == StepOpen;
public bool IsStepShowSeed => SetupStep == StepShowSeed;
@@ -191,6 +194,9 @@ public partial class MainWindowViewModel : ViewModelBase
public bool IsStepPassphrase => SetupStep == StepPassphrase;
public bool IsStepPassword => SetupStep == StepPassword;
/// <summary>Percorso dati predefinito, mostrato al primo avvio.</summary>
public string DefaultDataPath => AppPaths.DefaultDataRoot();
/// <summary>True quando il flusso è "ripristina" (parole inserite dall'utente).</summary>
private bool _isRestoreFlow;
@@ -375,6 +381,11 @@ public partial class MainWindowViewModel : ViewModelBase
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
@@ -413,6 +424,34 @@ public partial class MainWindowViewModel : ViewModelBase
private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net));
/// <summary>Primo avvio: usa il percorso dati predefinito di piattaforma.</summary>
[RelayCommand]
private void UseDefaultDataLocation() => ApplyDataLocation(AppPaths.DefaultDataRoot());
/// <summary>
/// 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.
/// </summary>
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;
+17
View File
@@ -41,6 +41,23 @@
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"
HorizontalAlignment="Center"/>
<!-- Passo 0 (primo avvio): dove salvare i dati -->
<StackPanel IsVisible="{Binding IsStepDataLocation}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.data.title]}" FontSize="18" FontWeight="Bold"/>
<TextBlock Text="{Binding Loc[wiz.data.info]}" TextWrapping="Wrap" Foreground="Gray"/>
<StackPanel Spacing="4">
<TextBlock Text="{Binding Loc[wiz.data.default]}" FontSize="11" Foreground="Gray"/>
<SelectableTextBlock Text="{Binding DefaultDataPath}"
FontFamily="monospace" FontSize="13" TextWrapping="Wrap"/>
</StackPanel>
<Button Content="{Binding Loc[wiz.data.usedefault]}" FontSize="16" Classes="accent"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding UseDefaultDataLocationCommand}"/>
<Button Content="{Binding Loc[wiz.data.choose]}" FontSize="16"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Click="OnChooseDataFolderClick"/>
</StackPanel>
<!-- Passo 1: scelta iniziale -->
<StackPanel IsVisible="{Binding IsStepStart}" Spacing="12">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
+15
View File
@@ -72,6 +72,21 @@ public partial class MainWindow : Window
vm.IsServerSettingsOpen = false;
}
private async void OnChooseDataFolderClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm)
return;
var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Cartella dati Palladium Wallet",
AllowMultiple = false,
});
if (folders.FirstOrDefault()?.TryGetLocalPath() is { } path)
vm.ApplyDataLocation(path);
}
private void OnConnectionStatusTapped(object? sender, TappedEventArgs e)
{
if (DataContext is MainWindowViewModel vm)
+88 -11
View File
@@ -3,27 +3,104 @@ using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Core.Storage;
/// <summary>
/// Percorsi dati per piattaforma (blueprint §8): ~/.palladium-wallet (Linux) o
/// %APPDATA%/PalladiumWallet (Windows), con sottocartella per rete. La modalità
/// portable (dati accanto all'eseguibile) si attiva se accanto all'eseguibile
/// esiste una cartella "palladium-data".
/// Percorsi dati per piattaforma (blueprint §8). La radice dati può essere:
/// 1. <b>portable</b>: cartella "palladium-data" accanto all'eseguibile;
/// 2. <b>personalizzata</b>: scelta dall'utente al primo avvio e memorizzata in
/// un piccolo file "puntatore" in una posizione di bootstrap fissa;
/// 3. <b>legacy</b>: vecchia posizione (%APPDATA%/PalladiumWallet) se contiene già dati;
/// 4. <b>default</b>: ~/.PalladiumWallet (Linux/macOS) o %ProgramFiles%\PalladiumWallet (Windows).
/// Sotto la radice c'è una sottocartella per rete (config, wallet, header, certificati).
/// </summary>
public static class AppPaths
{
public const string PortableDirName = "palladium-data";
/// <summary>Nome cartella applicazione, usato nei vari percorsi.</summary>
public const string AppDirName = "PalladiumWallet";
/// <summary>Override esplicito della radice dati (es. CLI --data-dir). Ha priorità su tutto.</summary>
public static string? OverrideDataRoot { get; set; }
/// <summary>
/// Radice dati predefinita: cartella nascosta nella home dell'utente
/// (%USERPROFILE%\.PalladiumWallet su Windows, ~/.PalladiumWallet su Linux/macOS).
/// Per-utente e sempre scrivibile, senza privilegi di amministratore.
/// </summary>
public static string DefaultDataRoot()
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return Path.Combine(home, "." + AppDirName);
}
/// <summary>Vecchia radice (%APPDATA%/PalladiumWallet, ~/.config su Linux): mantenuta
/// per non orfanizzare i dati di installazioni precedenti.</summary>
private static string LegacyDataRoot() =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
AppDirName);
/// <summary>File puntatore alla radice dati scelta dall'utente. Vive in una
/// posizione di bootstrap sempre scrivibile e indipendente dalla radice dati.</summary>
private static string LocationPointerPath() =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
AppDirName, "data-location");
private static string PortableRoot() =>
Path.Combine(AppContext.BaseDirectory, PortableDirName);
private static bool HasData(string root) =>
Directory.Exists(root) && Directory.EnumerateFileSystemEntries(root).Any();
/// <summary>Radice dati effettiva, secondo l'ordine di precedenza documentato in classe.</summary>
public static string DataRoot()
{
var portable = Path.Combine(AppContext.BaseDirectory, PortableDirName);
if (!string.IsNullOrEmpty(OverrideDataRoot))
return OverrideDataRoot;
var portable = PortableRoot();
if (Directory.Exists(portable))
return portable;
// Windows → %APPDATA%\PalladiumWallet
// Linux → $XDG_CONFIG_HOME/PalladiumWallet (default ~/.config/PalladiumWallet)
// macOS → ~/Library/Application Support/PalladiumWallet
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PalladiumWallet");
if (ReadPointer() is { } custom)
return custom;
var legacy = LegacyDataRoot();
if (HasData(legacy))
return legacy;
return DefaultDataRoot();
}
/// <summary>
/// true se la posizione dei dati è già determinata e non serve chiederla
/// all'utente: modalità portable, override, puntatore già scritto, oppure
/// dati già presenti nella posizione legacy o nella default.
/// </summary>
public static bool IsDataLocationConfigured() =>
!string.IsNullOrEmpty(OverrideDataRoot)
|| Directory.Exists(PortableRoot())
|| ReadPointer() is not null
|| HasData(LegacyDataRoot())
|| HasData(DefaultDataRoot());
/// <summary>Memorizza la radice dati scelta dall'utente e la crea su disco.</summary>
public static void ConfigureDataLocation(string root)
{
root = Path.GetFullPath(root.Trim());
Directory.CreateDirectory(root);
var pointer = LocationPointerPath();
Directory.CreateDirectory(Path.GetDirectoryName(pointer)!);
File.WriteAllText(pointer, root);
}
private static string? ReadPointer()
{
var pointer = LocationPointerPath();
if (!File.Exists(pointer))
return null;
var path = File.ReadAllText(pointer).Trim();
return string.IsNullOrEmpty(path) ? null : path;
}
/// <summary>Cartella dati della rete (config, wallet, header, certificati).</summary>
@@ -197,7 +197,7 @@ public class TransactionFactoryTests
{
File.WriteAllText(path, "{ rotto ");
var loaded = PalladiumWallet.Core.Storage.AppConfig.Load(path);
Assert.Equal("it", loaded.Language);
Assert.Equal("en", loaded.Language);
Assert.Equal("PLM", loaded.Unit);
}
finally