From 5af6ef23fea44c8189079156431bb4b2ceb5cacc Mon Sep 17 00:00:00 2001 From: Giuseppe Imperato Date: Tue, 5 May 2026 13:02:01 +0200 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20code=20cleanup=20=E2=80=94=20na?= =?UTF-8?q?med=20constants,=20dedup,=20naming=20consistency,=20XML=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CardTypeDetector: extract BinRanges nested class with 10 named constants for all BIN/IIN prefix ranges (MasterCard, Discover, JCB, Diners Club) - PasswordStrengthAnalyzer: extract TimeConstants nested class (SecondsPerMinute through SecondsPerMillennium) replacing magic literals in EstimateCrackTime() - PasswordGenerator: add MinPasswordLength=8 / MaxPasswordLength=128 constants - MergeService: add NoteHashSnippetLength=256 constant; extract MergeCollection() generic helper — eliminates 4 identical merge loops (~44 duplicated lines) - Add Helpers/ListViewHelpers.cs with ShowSavedToast(TeachingTip, Action?) centralising identical toast boilerplate from 4 list-view code-behind files - Rename _res → _resourceLoader in all 9 view files for naming clarity - DashboardViewModel: add XML documentation to all 18 ObservableProperty declarations (greeting, stat cards, weekly activity, health, search) Build: 0 errors, 0 warnings. Tests: 176/176 pass. Co-Authored-By: Claude Sonnet 4.6 --- src/PassKey.Core/Services/CardTypeDetector.cs | 40 +++++- src/PassKey.Core/Services/MergeService.cs | 102 +++++--------- .../Services/PasswordGenerator.cs | 8 +- .../Services/PasswordStrengthAnalyzer.cs | 25 +++- .../Helpers/ListViewHelpers.cs | 36 +++++ .../ViewModels/DashboardViewModel.cs | 70 ++++++++-- .../Views/CreditCardDetailView.xaml.cs | 14 +- .../Views/CreditCardsListView.xaml.cs | 19 +-- .../Views/DashboardView.xaml.cs | 50 +++---- .../Views/IdentitiesListView.xaml.cs | 19 +-- .../Views/IdentityDetailView.xaml.cs | 8 +- .../Views/PasswordDetailView.xaml.cs | 16 +-- .../Views/PasswordsListView.xaml.cs | 21 +-- .../Views/SecureNotesListView.xaml.cs | 24 +--- .../Views/SettingsView.xaml.cs | 130 +++++++++--------- 15 files changed, 326 insertions(+), 256 deletions(-) create mode 100644 src/PassKey.Desktop/Helpers/ListViewHelpers.cs diff --git a/src/PassKey.Core/Services/CardTypeDetector.cs b/src/PassKey.Core/Services/CardTypeDetector.cs index 8f74c51..640b34f 100644 --- a/src/PassKey.Core/Services/CardTypeDetector.cs +++ b/src/PassKey.Core/Services/CardTypeDetector.cs @@ -4,6 +4,34 @@ namespace PassKey.Core.Services; public static class CardTypeDetector { + /// + /// BIN (Bank Identification Number) / IIN prefix ranges used for card-type detection. + /// Sources: EMVCo IIN Registry; updated ranges as of 2024. + /// + private static class BinRanges + { + // MasterCard: standard range 51-55 is checked inline with a 2-digit prefix; + // the expanded range covers re-issued BINs introduced in 2017. + internal const int MasterCardExpandedStart = 2221; + internal const int MasterCardExpandedEnd = 2720; + + // Discover: 3-digit prefix range (644-649) + internal const int DiscoverRange3Start = 644; + internal const int DiscoverRange3End = 649; + + // Discover: 6-digit IIN range (co-branded UnionPay; 622126-622925) + internal const int DiscoverRange6Start = 622126; + internal const int DiscoverRange6End = 622925; + + // JCB: 4-digit prefix range (3528-3589) + internal const int JcbRangeStart = 3528; + internal const int JcbRangeEnd = 3589; + + // Diners Club Classic: 3-digit prefix range (300-305) + internal const int DinersRange3Start = 300; + internal const int DinersRange3End = 305; + } + /// /// Detects the card type from the card number using BIN prefix tables. /// @@ -24,7 +52,7 @@ public static CardType Detect(string cardNumber) if (digits.StartsWith('4')) return CardType.Visa; - // MasterCard: 51-55 or 2221-2720 + // MasterCard: 51-55 or expanded range 2221-2720 if (digits.Length >= 2) { var prefix2 = int.Parse(digits[..2]); @@ -34,7 +62,7 @@ public static CardType Detect(string cardNumber) if (digits.Length >= 4) { var prefix4 = int.Parse(digits[..4]); - if (prefix4 >= 2221 && prefix4 <= 2720) + if (prefix4 >= BinRanges.MasterCardExpandedStart && prefix4 <= BinRanges.MasterCardExpandedEnd) return CardType.MasterCard; } @@ -44,13 +72,13 @@ public static CardType Detect(string cardNumber) if (digits.Length >= 3) { var prefix3 = int.Parse(digits[..3]); - if (prefix3 >= 644 && prefix3 <= 649) + if (prefix3 >= BinRanges.DiscoverRange3Start && prefix3 <= BinRanges.DiscoverRange3End) return CardType.Discover; } if (digits.Length >= 6) { var prefix6 = int.Parse(digits[..6]); - if (prefix6 >= 622126 && prefix6 <= 622925) + if (prefix6 >= BinRanges.DiscoverRange6Start && prefix6 <= BinRanges.DiscoverRange6End) return CardType.Discover; } @@ -58,7 +86,7 @@ public static CardType Detect(string cardNumber) if (digits.Length >= 4) { var prefix4 = int.Parse(digits[..4]); - if (prefix4 >= 3528 && prefix4 <= 3589) + if (prefix4 >= BinRanges.JcbRangeStart && prefix4 <= BinRanges.JcbRangeEnd) return CardType.JCB; } @@ -68,7 +96,7 @@ public static CardType Detect(string cardNumber) if (digits.Length >= 3) { var prefix3 = int.Parse(digits[..3]); - if (prefix3 >= 300 && prefix3 <= 305) + if (prefix3 >= BinRanges.DinersRange3Start && prefix3 <= BinRanges.DinersRange3End) return CardType.DinersClub; } diff --git a/src/PassKey.Core/Services/MergeService.cs b/src/PassKey.Core/Services/MergeService.cs index ede6de1..0e40955 100644 --- a/src/PassKey.Core/Services/MergeService.cs +++ b/src/PassKey.Core/Services/MergeService.cs @@ -11,82 +11,56 @@ public ImportResult MergeInto(Vault target, Vault source, ImportMergeStrategy st ArgumentNullException.ThrowIfNull(target); ArgumentNullException.ThrowIfNull(source); - int pwImported = 0, cardImported = 0, idImported = 0, noteImported = 0; - int skipped = 0, overwritten = 0; + var (pwImported, pwSkipped, pwOverwritten) = MergeCollection(target.Passwords, source.Passwords, strategy, ComputePasswordHash); + var (cardImported, cardSkipped, cardOverwritten) = MergeCollection(target.CreditCards, source.CreditCards, strategy, ComputeCardHash); + var (idImported, idSkipped, idOverwritten) = MergeCollection(target.Identities, source.Identities, strategy, ComputeIdentityHash); + var (noteImported, noteSkipped, noteOverwritten) = MergeCollection(target.SecureNotes, source.SecureNotes, strategy, ComputeNoteHash); - // Build hash sets of existing entries - var existingPwHashes = BuildHashSet(target.Passwords, ComputePasswordHash); - var existingCardHashes = BuildHashSet(target.CreditCards, ComputeCardHash); - var existingIdHashes = BuildHashSet(target.Identities, ComputeIdentityHash); - var existingNoteHashes = BuildHashSet(target.SecureNotes, ComputeNoteHash); + target.LastModified = DateTime.UtcNow; - // Merge passwords - foreach (var entry in source.Passwords) + return new ImportResult { - var hash = ComputePasswordHash(entry); - var result = MergeEntry(target.Passwords, entry, hash, existingPwHashes, strategy, ComputePasswordHash); - switch (result) - { - case MergeAction.Imported: pwImported++; break; - case MergeAction.Skipped: skipped++; break; - case MergeAction.Overwritten: overwritten++; break; - } - } + PasswordsImported = pwImported, + CardsImported = cardImported, + IdentitiesImported = idImported, + NotesImported = noteImported, + Skipped = pwSkipped + cardSkipped + idSkipped + noteSkipped, + Overwritten = pwOverwritten + cardOverwritten + idOverwritten + noteOverwritten + }; + } - // Merge credit cards - foreach (var entry in source.CreditCards) - { - var hash = ComputeCardHash(entry); - var result = MergeEntry(target.CreditCards, entry, hash, existingCardHashes, strategy, ComputeCardHash); - switch (result) - { - case MergeAction.Imported: cardImported++; break; - case MergeAction.Skipped: skipped++; break; - case MergeAction.Overwritten: overwritten++; break; - } - } + private enum MergeAction { Imported, Skipped, Overwritten } - // Merge identities - foreach (var entry in source.Identities) - { - var hash = ComputeIdentityHash(entry); - var result = MergeEntry(target.Identities, entry, hash, existingIdHashes, strategy, ComputeIdentityHash); - switch (result) - { - case MergeAction.Imported: idImported++; break; - case MergeAction.Skipped: skipped++; break; - case MergeAction.Overwritten: overwritten++; break; - } - } + private const int NoteHashSnippetLength = 256; + + /// + /// Merges all entries from into + /// applying the given for duplicates. + /// Returns a tuple of (imported, skipped, overwritten) counts. + /// + private static (int imported, int skipped, int overwritten) MergeCollection( + List targetList, + List sourceList, + ImportMergeStrategy strategy, + Func hashFunc) where T : class + { + int imported = 0, skipped = 0, overwritten = 0; + var existingHashes = BuildHashSet(targetList, hashFunc); - // Merge secure notes - foreach (var entry in source.SecureNotes) + foreach (var entry in sourceList) { - var hash = ComputeNoteHash(entry); - var result = MergeEntry(target.SecureNotes, entry, hash, existingNoteHashes, strategy, ComputeNoteHash); + var hash = hashFunc(entry); + var result = MergeEntry(targetList, entry, hash, existingHashes, strategy, hashFunc); switch (result) { - case MergeAction.Imported: noteImported++; break; - case MergeAction.Skipped: skipped++; break; + case MergeAction.Imported: imported++; break; + case MergeAction.Skipped: skipped++; break; case MergeAction.Overwritten: overwritten++; break; } } - - target.LastModified = DateTime.UtcNow; - - return new ImportResult - { - PasswordsImported = pwImported, - CardsImported = cardImported, - IdentitiesImported = idImported, - NotesImported = noteImported, - Skipped = skipped, - Overwritten = overwritten - }; + return (imported, skipped, overwritten); } - private enum MergeAction { Imported, Skipped, Overwritten } - private static MergeAction MergeEntry( List targetList, T entry, string hash, Dictionary existingHashes, @@ -165,8 +139,8 @@ internal static string ComputeIdentityHash(IdentityEntry entry) internal static string ComputeNoteHash(SecureNoteEntry entry) { - var contentSnippet = entry.Content.Length > 256 - ? entry.Content[..256] + var contentSnippet = entry.Content.Length > NoteHashSnippetLength + ? entry.Content[..NoteHashSnippetLength] : entry.Content; var input = $"{entry.Title.ToLowerInvariant()}|{contentSnippet}"; return ComputeSha256(input); diff --git a/src/PassKey.Core/Services/PasswordGenerator.cs b/src/PassKey.Core/Services/PasswordGenerator.cs index f8ad768..c2af863 100644 --- a/src/PassKey.Core/Services/PasswordGenerator.cs +++ b/src/PassKey.Core/Services/PasswordGenerator.cs @@ -10,11 +10,15 @@ public sealed class PasswordGenerator : IPasswordGenerator private const string Symbols = "!@#$%^&*()-_=+[]{}|;:',.<>?/~`"; private const string AmbiguousChars = "0O1lI|"; + private const int MinPasswordLength = 8; + private const int MaxPasswordLength = 128; + public string Generate(PasswordGeneratorOptions options) { ArgumentNullException.ThrowIfNull(options); - if (options.Length < 8 || options.Length > 128) - throw new ArgumentOutOfRangeException(nameof(options), "Length must be between 8 and 128."); + if (options.Length < MinPasswordLength || options.Length > MaxPasswordLength) + throw new ArgumentOutOfRangeException(nameof(options), + $"Length must be between {MinPasswordLength} and {MaxPasswordLength}."); var charPool = BuildCharPool(options); if (charPool.Length == 0) diff --git a/src/PassKey.Core/Services/PasswordStrengthAnalyzer.cs b/src/PassKey.Core/Services/PasswordStrengthAnalyzer.cs index bf81fa4..31a0435 100644 --- a/src/PassKey.Core/Services/PasswordStrengthAnalyzer.cs +++ b/src/PassKey.Core/Services/PasswordStrengthAnalyzer.cs @@ -104,6 +104,19 @@ private static bool ContainsCommonPattern(string password) return false; } + /// + /// Time conversion constants used when formatting the estimated crack-time string. + /// + private static class TimeConstants + { + internal const double SecondsPerMinute = 60; + internal const double SecondsPerHour = 3_600; // 60 * 60 + internal const double SecondsPerDay = 86_400; // 60 * 60 * 24 + internal const double SecondsPerYear = 31_536_000; // 365 * 24 * 60 * 60 + internal const double SecondsPerCentury = SecondsPerYear * 100; + internal const double SecondsPerMillennium = SecondsPerYear * 1_000; + } + private static string EstimateCrackTime(int length, bool hasUpper, bool hasLower, bool hasDigit, bool hasSymbol) { var charsetSize = 0; @@ -121,12 +134,12 @@ private static string EstimateCrackTime(int length, bool hasUpper, bool hasLower return seconds switch { < 1 => "instant", - < 60 => "seconds", - < 3600 => $"{(int)(seconds / 60)} minutes", - < 86400 => $"{(int)(seconds / 3600)} hours", - < 31536000 => $"{(int)(seconds / 86400)} days", - < 31536000.0 * 100 => $"{(int)(seconds / 31536000)} years", - < 31536000.0 * 1000 => "centuries", + < TimeConstants.SecondsPerMinute => "seconds", + < TimeConstants.SecondsPerHour => $"{(int)(seconds / TimeConstants.SecondsPerMinute)} minutes", + < TimeConstants.SecondsPerDay => $"{(int)(seconds / TimeConstants.SecondsPerHour)} hours", + < TimeConstants.SecondsPerYear => $"{(int)(seconds / TimeConstants.SecondsPerDay)} days", + < TimeConstants.SecondsPerCentury => $"{(int)(seconds / TimeConstants.SecondsPerYear)} years", + < TimeConstants.SecondsPerMillennium => "centuries", _ => "millennia" }; } diff --git a/src/PassKey.Desktop/Helpers/ListViewHelpers.cs b/src/PassKey.Desktop/Helpers/ListViewHelpers.cs new file mode 100644 index 0000000..728d287 --- /dev/null +++ b/src/PassKey.Desktop/Helpers/ListViewHelpers.cs @@ -0,0 +1,36 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace PassKey.Desktop.Helpers; + +/// +/// Shared UI helpers for list-view code-behind classes. +/// Centralises boilerplate that would otherwise be duplicated across +/// PasswordsListView, CreditCardsListView, IdentitiesListView +/// and SecureNotesListView. +/// +internal static class ListViewHelpers +{ + /// + /// Opens for 2 seconds, then closes it automatically + /// via a . + /// + /// The to show (e.g. SavedTip). + /// + /// Optional action invoked immediately after opening the tip. + /// Used by SecureNotesListView to trigger the accessibility announcer. + /// + internal static void ShowSavedToast(TeachingTip tip, Action? extraAction = null) + { + tip.IsOpen = true; + extraAction?.Invoke(); + + var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) }; + timer.Tick += (s, _) => + { + tip.IsOpen = false; + ((DispatcherTimer)s!).Stop(); + }; + timer.Start(); + } +} diff --git a/src/PassKey.Desktop/ViewModels/DashboardViewModel.cs b/src/PassKey.Desktop/ViewModels/DashboardViewModel.cs index b2e48aa..aecde50 100644 --- a/src/PassKey.Desktop/ViewModels/DashboardViewModel.cs +++ b/src/PassKey.Desktop/ViewModels/DashboardViewModel.cs @@ -34,91 +34,143 @@ public partial class DashboardViewModel : ObservableObject private string _deletedIdentity = "Identità eliminata"; private string _deletedNote = "Nota eliminata"; - // Greeting + // ── Greeting ───────────────────────────────────────────────────────────── + + /// + /// Personalised greeting shown at the top of the dashboard. + /// Text is time-dependent: morning / afternoon / evening variant. + /// Updated each time runs. + /// [ObservableProperty] public partial string GreetingMessage { get; set; } = string.Empty; - // Stat card totals + // ── Stat card totals ───────────────────────────────────────────────────── + + /// Total count of password entries currently in the vault. [ObservableProperty] public partial int TotalPasswords { get; set; } + /// Total count of credit card entries currently in the vault. [ObservableProperty] public partial int TotalCards { get; set; } + /// Total count of identity profiles currently in the vault. [ObservableProperty] public partial int TotalIdentities { get; set; } + /// Total count of secure notes currently in the vault. [ObservableProperty] public partial int TotalNotes { get; set; } - // Per-card weekly activity (4 types × 3 actions = 12 properties) + // ── Weekly activity counters (4 types × 3 actions = 12 properties) ─────── + + /// Number of password entries added in the past 7 days. [ObservableProperty] public partial int PasswordsAdded { get; set; } + /// Number of password entries deleted in the past 7 days. [ObservableProperty] public partial int PasswordsRemoved { get; set; } + /// Number of password entries edited in the past 7 days. [ObservableProperty] public partial int PasswordsModified { get; set; } + /// Number of credit card entries added in the past 7 days. [ObservableProperty] public partial int CardsAdded { get; set; } + /// Number of credit card entries deleted in the past 7 days. [ObservableProperty] public partial int CardsRemoved { get; set; } + /// Number of credit card entries edited in the past 7 days. [ObservableProperty] public partial int CardsModified { get; set; } + /// Number of identity profiles added in the past 7 days. [ObservableProperty] public partial int IdentitiesAdded { get; set; } + /// Number of identity profiles deleted in the past 7 days. [ObservableProperty] public partial int IdentitiesRemoved { get; set; } + /// Number of identity profiles edited in the past 7 days. [ObservableProperty] public partial int IdentitiesModified { get; set; } + /// Number of secure notes added in the past 7 days. [ObservableProperty] public partial int NotesAdded { get; set; } + /// Number of secure notes deleted in the past 7 days. [ObservableProperty] public partial int NotesRemoved { get; set; } + /// Number of secure notes edited in the past 7 days. [ObservableProperty] public partial int NotesModified { get; set; } - // Password health + // ── Vault health ───────────────────────────────────────────────────────── + + /// + /// Overall vault health score (0–100). + /// Computed from the ratio of strong vs. weak passwords in the vault. + /// [ObservableProperty] public partial int VaultHealthScore { get; set; } + /// Count of password entries rated Weak or VeryWeak by the strength analyser. [ObservableProperty] public partial int WeakPasswordCount { get; set; } - // Expiring cards + // ── Expiring credit cards ───────────────────────────────────────────────── + + /// Number of credit cards expiring within the next 60 days. [ObservableProperty] public partial int ExpiringCardsCount { get; set; } + /// when at least one card is expiring soon; drives the warning banner visibility. [ObservableProperty] public partial bool HasExpiringCards { get; set; } - // Recent activity + // ── Recent activity ─────────────────────────────────────────────────────── + + /// Up to 15 most recent vault activity items, shown in the dashboard timeline. public ObservableCollection RecentItems { get; } = []; - // Empty state + // ── Empty state ─────────────────────────────────────────────────────────── + + /// + /// when the vault contains no entries of any type. + /// Drives the dashboard empty-state placeholder visibility. + /// [ObservableProperty] public partial bool IsVaultEmpty { get; set; } - // Search + // ── Search ──────────────────────────────────────────────────────────────── + + /// + /// Current search query entered by the user in the dashboard search box. + /// Setting this property triggers a reactive search across all vault entry types. + /// [ObservableProperty] public partial string SearchQuery { get; set; } = string.Empty; + /// Flat list of search results across all vault sections, bound to the search results panel. public ObservableCollection SearchResults { get; } = []; + /// when is non-empty; drives the results panel visibility. [ObservableProperty] public partial bool HasSearchResults { get; set; } - // Navigation event: fired when user clicks a recent item or search result + // ── Navigation ──────────────────────────────────────────────────────────── + + /// + /// Fired when the user clicks a recent-activity item or a search result. + /// Parameters: vault section identifier (e.g. "Passwords") and entry . + /// public event Action? NavigateToItemRequested; public DashboardViewModel( diff --git a/src/PassKey.Desktop/Views/CreditCardDetailView.xaml.cs b/src/PassKey.Desktop/Views/CreditCardDetailView.xaml.cs index 78bcd43..7a20144 100644 --- a/src/PassKey.Desktop/Views/CreditCardDetailView.xaml.cs +++ b/src/PassKey.Desktop/Views/CreditCardDetailView.xaml.cs @@ -16,7 +16,7 @@ public sealed partial class CreditCardDetailView : UserControl { private CreditCardDetailViewModel? _viewModel; private bool _updatingFromVm; - private readonly Microsoft.Windows.ApplicationModel.Resources.ResourceLoader _res = new(); + private readonly Microsoft.Windows.ApplicationModel.Resources.ResourceLoader _resourceLoader = new(); /// /// All 10 accent colors for the swatch selector. @@ -100,8 +100,8 @@ public void SetViewModel(CreditCardDetailViewModel vm) // Subtitle PanelSubtitle.Text = isEdit - ? _res.GetString("CardPanelSubtitleEdit") - : _res.GetString("CardPanelSubtitleNew"); + ? _resourceLoader.GetString("CardPanelSubtitleEdit") + : _resourceLoader.GetString("CardPanelSubtitleNew"); // Hook SecureInputBox events CvvInput.PasswordChanged += OnCvvChanged; @@ -265,7 +265,7 @@ private void UpdateCardTypeIndicator() CardType.JCB => "JCB", CardType.Maestro => "Maestro", CardType.DinersClub => "Diners Club", - _ => _res.GetString("CardTypeUnknown") + _ => _resourceLoader.GetString("CardTypeUnknown") }; // Luhn validation @@ -278,14 +278,14 @@ private void UpdateCardTypeIndicator() { LuhnIcon.Glyph = "\uE73E"; // checkmark LuhnIcon.Foreground = (Brush)Application.Current.Resources["LuhnValidBrush"]; - LuhnLabel.Text = _res.GetString("LuhnValid"); + LuhnLabel.Text = _resourceLoader.GetString("LuhnValid"); LuhnLabel.Foreground = (Brush)Application.Current.Resources["LuhnValidBrush"]; } else { LuhnIcon.Glyph = "\uE711"; // dismiss LuhnIcon.Foreground = (Brush)Application.Current.Resources["LuhnInvalidBrush"]; - LuhnLabel.Text = _res.GetString("LuhnInvalid"); + LuhnLabel.Text = _resourceLoader.GetString("LuhnInvalid"); LuhnLabel.Foreground = (Brush)Application.Current.Resources["LuhnInvalidBrush"]; } } @@ -318,7 +318,7 @@ private void UpdateSavingState(bool saving) { SaveProgress.IsActive = saving; SaveProgress.Visibility = saving ? Visibility.Visible : Visibility.Collapsed; - SaveButtonText.Text = saving ? _res.GetString("SaveInProgress") : _res.GetString("ButtonSaveLabel/Text"); + SaveButtonText.Text = saving ? _resourceLoader.GetString("SaveInProgress") : _resourceLoader.GetString("ButtonSaveLabel/Text"); SaveButton.IsEnabled = !saving; } diff --git a/src/PassKey.Desktop/Views/CreditCardsListView.xaml.cs b/src/PassKey.Desktop/Views/CreditCardsListView.xaml.cs index 58aa62e..cecd707 100644 --- a/src/PassKey.Desktop/Views/CreditCardsListView.xaml.cs +++ b/src/PassKey.Desktop/Views/CreditCardsListView.xaml.cs @@ -6,6 +6,7 @@ using PassKey.Core.Models; using PassKey.Core.Services; using PassKey.Desktop.Controls; +using PassKey.Desktop.Helpers; using PassKey.Desktop.ViewModels; namespace PassKey.Desktop.Views; @@ -16,7 +17,7 @@ namespace PassKey.Desktop.Views; public sealed partial class CreditCardsListView : UserControl { private CreditCardsListViewModel? _viewModel; - private readonly ResourceLoader _res = new(); + private readonly ResourceLoader _resourceLoader = new(); public CreditCardsListView() { @@ -29,8 +30,8 @@ public async void SetViewModel(CreditCardsListViewModel vm) DataContext = vm; // Apply localized empty-state strings (guarantees correct language on every OS locale). - EmptyState.Title = _res.GetString("EmptyCardsTitle"); - EmptyState.Subtitle = _res.GetString("EmptyCardsSubtitle"); + EmptyState.Title = _resourceLoader.GetString("EmptyCardsTitle"); + EmptyState.Subtitle = _resourceLoader.GetString("EmptyCardsSubtitle"); vm.PropertyChanged += OnViewModelPropertyChanged; vm.SaveCompleted += ShowSavedToast; @@ -208,17 +209,7 @@ private void EditEntry_Click(object sender, RoutedEventArgs e) // --- Toast --- - public void ShowSavedToast() - { - SavedTip.IsOpen = true; - var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) }; - timer.Tick += (s, _) => - { - SavedTip.IsOpen = false; - ((DispatcherTimer)s!).Stop(); - }; - timer.Start(); - } + public void ShowSavedToast() => ListViewHelpers.ShowSavedToast(SavedTip); // Hover effects — show/hide action buttons private void Row_PointerEntered(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e) diff --git a/src/PassKey.Desktop/Views/DashboardView.xaml.cs b/src/PassKey.Desktop/Views/DashboardView.xaml.cs index 663c8de..1249a7b 100644 --- a/src/PassKey.Desktop/Views/DashboardView.xaml.cs +++ b/src/PassKey.Desktop/Views/DashboardView.xaml.cs @@ -13,18 +13,18 @@ namespace PassKey.Desktop.Views; public sealed partial class DashboardView : UserControl { private DashboardViewModel? _viewModel; - private readonly ResourceLoader _res = new(); + private readonly ResourceLoader _resourceLoader = new(); private DispatcherTimer? _searchTimer; public DashboardView() { InitializeComponent(); - EmptyState.Title = _res.GetString("EmptyDashboardTitle"); - EmptyState.Subtitle = _res.GetString("EmptyDashboardSubtitle"); - EmptyState.PrimaryActionText = _res.GetString("AddPassword"); - EmptyState.SecondaryActionText = _res.GetString("ImportData"); + EmptyState.Title = _resourceLoader.GetString("EmptyDashboardTitle"); + EmptyState.Subtitle = _resourceLoader.GetString("EmptyDashboardSubtitle"); + EmptyState.PrimaryActionText = _resourceLoader.GetString("AddPassword"); + EmptyState.SecondaryActionText = _resourceLoader.GetString("ImportData"); - VaultOverviewTitle.Text = _res.GetString("DashVaultOverview"); + VaultOverviewTitle.Text = _resourceLoader.GetString("DashVaultOverview"); } public async void SetViewModel(DashboardViewModel vm) @@ -36,20 +36,20 @@ public async void SetViewModel(DashboardViewModel vm) // Pass localized resources to ViewModel vm.SetGreetingResources( - _res.GetString("GreetingMorning"), - _res.GetString("GreetingAfternoon"), - _res.GetString("GreetingEvening")); + _resourceLoader.GetString("GreetingMorning"), + _resourceLoader.GetString("GreetingAfternoon"), + _resourceLoader.GetString("GreetingEvening")); vm.SetActionLabels( - _res.GetString("ActionCreated"), - _res.GetString("ActionModified"), - _res.GetString("ActionDeleted")); + _resourceLoader.GetString("ActionCreated"), + _resourceLoader.GetString("ActionModified"), + _resourceLoader.GetString("ActionDeleted")); vm.SetDeletedLabels( - _res.GetString("DeletedPassword"), - _res.GetString("DeletedCard"), - _res.GetString("DeletedIdentity"), - _res.GetString("DeletedNote")); + _resourceLoader.GetString("DeletedPassword"), + _resourceLoader.GetString("DeletedCard"), + _resourceLoader.GetString("DeletedIdentity"), + _resourceLoader.GetString("DeletedNote")); // Wire empty state buttons via PrimaryActionCommand/SecondaryActionCommand EmptyState.PrimaryActionCommand = new CommunityToolkit.Mvvm.Input.RelayCommand( @@ -102,13 +102,13 @@ private void UpdateUI() NotesCount.Text = _viewModel.TotalNotes.ToString(); // Stat card subtitles - PasswordsSubtitle.Text = _res.GetString("DashSubtitlePasswords"); - CardsSubtitle.Text = _res.GetString("DashSubtitleCards"); - IdentitiesSubtitle.Text = _res.GetString("DashSubtitleIdentities"); - NotesSubtitle.Text = _res.GetString("DashSubtitleNotes"); + PasswordsSubtitle.Text = _resourceLoader.GetString("DashSubtitlePasswords"); + CardsSubtitle.Text = _resourceLoader.GetString("DashSubtitleCards"); + IdentitiesSubtitle.Text = _resourceLoader.GetString("DashSubtitleIdentities"); + NotesSubtitle.Text = _resourceLoader.GetString("DashSubtitleNotes"); // Per-card compact activity (3 indicators each) - var noChangesLabel = _res.GetString("DashCardNoChanges"); + var noChangesLabel = _resourceLoader.GetString("DashCardNoChanges"); UpdateCardActivity(_viewModel.PasswordsAdded, _viewModel.PasswordsRemoved, _viewModel.PasswordsModified, PwActivityRow, PwNoChanges, PwAdded, PwRemoved, PwModified, noChangesLabel); UpdateCardActivity(_viewModel.CardsAdded, _viewModel.CardsRemoved, _viewModel.CardsModified, @@ -125,10 +125,10 @@ private void UpdateUI() HealthRing.Value = _viewModel.VaultHealthScore; HealthScoreText.Text = $"{_viewModel.VaultHealthScore}%"; HealthRing.Foreground = GetHealthBrush(_viewModel.VaultHealthScore); - HealthTitle.Text = _res.GetString("DashHealthTitle"); + HealthTitle.Text = _resourceLoader.GetString("DashHealthTitle"); HealthSubtitle.Text = _viewModel.WeakPasswordCount > 0 - ? string.Format(_res.GetString("DashHealthWeak"), _viewModel.WeakPasswordCount) - : _res.GetString("DashHealthGood"); + ? string.Format(_resourceLoader.GetString("DashHealthWeak"), _viewModel.WeakPasswordCount) + : _resourceLoader.GetString("DashHealthGood"); } else { @@ -141,7 +141,7 @@ private void UpdateUI() ExpiringCardsAlert.Visibility = Visibility.Visible; ExpiringCardsAlert.IsOpen = true; ExpiringCardsAlert.Message = string.Format( - _res.GetString("DashExpiringCards"), _viewModel.ExpiringCardsCount); + _resourceLoader.GetString("DashExpiringCards"), _viewModel.ExpiringCardsCount); } else { diff --git a/src/PassKey.Desktop/Views/IdentitiesListView.xaml.cs b/src/PassKey.Desktop/Views/IdentitiesListView.xaml.cs index 9eda370..3876d2e 100644 --- a/src/PassKey.Desktop/Views/IdentitiesListView.xaml.cs +++ b/src/PassKey.Desktop/Views/IdentitiesListView.xaml.cs @@ -7,6 +7,7 @@ using Microsoft.UI.Xaml.Media; using Microsoft.Windows.ApplicationModel.Resources; using PassKey.Core.Models; +using PassKey.Desktop.Helpers; using PassKey.Desktop.ViewModels; namespace PassKey.Desktop.Views; @@ -17,7 +18,7 @@ namespace PassKey.Desktop.Views; public sealed partial class IdentitiesListView : UserControl { private IdentitiesListViewModel? _viewModel; - private readonly ResourceLoader _res = new(); + private readonly ResourceLoader _resourceLoader = new(); public IdentitiesListView() { @@ -33,8 +34,8 @@ public async void SetViewModel(IdentitiesListViewModel vm) vm.SaveCompleted += ShowSavedToast; // Apply localized empty-state strings (guarantees correct language on every OS locale). - EmptyState.Title = _res.GetString("EmptyIdentitiesTitle"); - EmptyState.Subtitle = _res.GetString("EmptyIdentitiesSubtitle"); + EmptyState.Title = _resourceLoader.GetString("EmptyIdentitiesTitle"); + EmptyState.Subtitle = _resourceLoader.GetString("EmptyIdentitiesSubtitle"); await vm.LoadEntriesCommand.ExecuteAsync(null); UpdateList(); UpdateEmptyState(); @@ -181,17 +182,7 @@ private void Card_PointerExited(object sender, Microsoft.UI.Xaml.Input.PointerRo } // Save confirmation toast - public void ShowSavedToast() - { - SavedTip.IsOpen = true; - var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) }; - timer.Tick += (s, _) => - { - SavedTip.IsOpen = false; - ((DispatcherTimer)s!).Stop(); - }; - timer.Start(); - } + public void ShowSavedToast() => ListViewHelpers.ShowSavedToast(SavedTip); // ── Formatters ──────────────────────────────────────────────────────────── diff --git a/src/PassKey.Desktop/Views/IdentityDetailView.xaml.cs b/src/PassKey.Desktop/Views/IdentityDetailView.xaml.cs index ac77527..4bc878d 100644 --- a/src/PassKey.Desktop/Views/IdentityDetailView.xaml.cs +++ b/src/PassKey.Desktop/Views/IdentityDetailView.xaml.cs @@ -13,7 +13,7 @@ public sealed partial class IdentityDetailView : UserControl { private IdentityDetailViewModel? _viewModel; private bool _updatingFromVm; - private readonly Microsoft.Windows.ApplicationModel.Resources.ResourceLoader _res = new(); + private readonly Microsoft.Windows.ApplicationModel.Resources.ResourceLoader _resourceLoader = new(); public IdentityDetailView() { @@ -68,8 +68,8 @@ public void SetViewModel(IdentityDetailViewModel vm) // Subtitle PanelSubtitle.Text = isEdit - ? _res.GetString("IdPanelSubtitleEdit") - : _res.GetString("IdPanelSubtitleNew"); + ? _resourceLoader.GetString("IdPanelSubtitleEdit") + : _resourceLoader.GetString("IdPanelSubtitleNew"); // Expand sections that have data in edit mode if (!string.IsNullOrWhiteSpace(vm.Street) || !string.IsNullOrWhiteSpace(vm.City) || @@ -236,7 +236,7 @@ private void UpdateSavingState(bool saving) { SaveProgress.IsActive = saving; SaveProgress.Visibility = saving ? Visibility.Visible : Visibility.Collapsed; - SaveButtonText.Text = saving ? _res.GetString("SaveInProgress") : _res.GetString("ButtonSave"); + SaveButtonText.Text = saving ? _resourceLoader.GetString("SaveInProgress") : _resourceLoader.GetString("ButtonSave"); SaveButton.IsEnabled = !saving; } diff --git a/src/PassKey.Desktop/Views/PasswordDetailView.xaml.cs b/src/PassKey.Desktop/Views/PasswordDetailView.xaml.cs index 96d8cf2..7aac1e8 100644 --- a/src/PassKey.Desktop/Views/PasswordDetailView.xaml.cs +++ b/src/PassKey.Desktop/Views/PasswordDetailView.xaml.cs @@ -17,7 +17,7 @@ public sealed partial class PasswordDetailView : UserControl { private PasswordDetailViewModel? _viewModel; private bool _updatingFromVm; - private readonly ResourceLoader _res = new(); + private readonly ResourceLoader _resourceLoader = new(); // 24 Segoe MDL2 Assets glyphs for the icon picker private static readonly string[] IconGlyphs = @@ -65,8 +65,8 @@ public void SetViewModel(PasswordDetailViewModel vm) // Subtitle PanelSubtitle.Text = isEdit - ? _res.GetString("PwPanelSubtitleEdit") - : _res.GetString("PwPanelSubtitleNew"); + ? _resourceLoader.GetString("PwPanelSubtitleEdit") + : _resourceLoader.GetString("PwPanelSubtitleNew"); // Strength indicator UpdateStrengthLabel(); @@ -219,11 +219,11 @@ private void UpdateStrengthLabel() StrengthLabel.Visibility = Visibility.Visible; StrengthLabel.Text = label switch { - "VeryWeak" => _res.GetString("StrengthVeryWeak"), - "Weak" => _res.GetString("StrengthWeak"), - "Medium" => _res.GetString("StrengthMedium"), - "Strong" => _res.GetString("StrengthStrong"), - "VeryStrong" => _res.GetString("StrengthVeryStrong"), + "VeryWeak" => _resourceLoader.GetString("StrengthVeryWeak"), + "Weak" => _resourceLoader.GetString("StrengthWeak"), + "Medium" => _resourceLoader.GetString("StrengthMedium"), + "Strong" => _resourceLoader.GetString("StrengthStrong"), + "VeryStrong" => _resourceLoader.GetString("StrengthVeryStrong"), _ => label }; StrengthLabel.Foreground = GetStrengthBrush(_viewModel.PasswordStrengthScore); diff --git a/src/PassKey.Desktop/Views/PasswordsListView.xaml.cs b/src/PassKey.Desktop/Views/PasswordsListView.xaml.cs index e3c31d7..cc25a6c 100644 --- a/src/PassKey.Desktop/Views/PasswordsListView.xaml.cs +++ b/src/PassKey.Desktop/Views/PasswordsListView.xaml.cs @@ -5,6 +5,7 @@ using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.Windows.ApplicationModel.Resources; using PassKey.Core.Models; +using PassKey.Desktop.Helpers; using PassKey.Desktop.ViewModels; using Windows.Storage.Streams; @@ -17,7 +18,7 @@ namespace PassKey.Desktop.Views; public sealed partial class PasswordsListView : UserControl { private PasswordsListViewModel? _viewModel; - private readonly ResourceLoader _res = new(); + private readonly ResourceLoader _resourceLoader = new(); public PasswordsListView() { @@ -30,8 +31,8 @@ public async void SetViewModel(PasswordsListViewModel vm) DataContext = vm; // Apply localized empty-state strings (guarantees correct language on every OS locale). - EmptyState.Title = _res.GetString("EmptyPasswordsTitle"); - EmptyState.Subtitle = _res.GetString("EmptyPasswordsSubtitle"); + EmptyState.Title = _resourceLoader.GetString("EmptyPasswordsTitle"); + EmptyState.Subtitle = _resourceLoader.GetString("EmptyPasswordsSubtitle"); vm.PropertyChanged += OnViewModelPropertyChanged; vm.SaveCompleted += ShowSavedToast; @@ -99,19 +100,9 @@ private void UpdateDetailContent() } } - // --- Toast (Step F) --- + // --- Toast --- - public void ShowSavedToast() - { - SavedTip.IsOpen = true; - var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) }; - timer.Tick += (s, _) => - { - SavedTip.IsOpen = false; - ((DispatcherTimer)s!).Stop(); - }; - timer.Start(); - } + public void ShowSavedToast() => ListViewHelpers.ShowSavedToast(SavedTip); // --- Search --- diff --git a/src/PassKey.Desktop/Views/SecureNotesListView.xaml.cs b/src/PassKey.Desktop/Views/SecureNotesListView.xaml.cs index 3feca18..a0c41a8 100644 --- a/src/PassKey.Desktop/Views/SecureNotesListView.xaml.cs +++ b/src/PassKey.Desktop/Views/SecureNotesListView.xaml.cs @@ -7,6 +7,7 @@ using Microsoft.Windows.ApplicationModel.Resources; using PassKey.Core.Constants; using PassKey.Core.Models; +using PassKey.Desktop.Helpers; using PassKey.Desktop.ViewModels; namespace PassKey.Desktop.Views; @@ -19,7 +20,7 @@ namespace PassKey.Desktop.Views; public sealed partial class SecureNotesListView : UserControl { private SecureNotesListViewModel? _viewModel; - private readonly ResourceLoader _res = new(); + private readonly ResourceLoader _resourceLoader = new(); public SecureNotesListView() { @@ -37,10 +38,10 @@ public async void SetViewModel(SecureNotesListViewModel vm) BuildCategoryFilter(); // Apply localized empty-state strings (guarantees correct language on every OS locale). - EmptyState.Title = _res.GetString("EmptyNotesTitle"); - EmptyState.Subtitle = _res.GetString("EmptyNotesSubtitle"); - FilteredEmptyState.Title = _res.GetString("EmptyFilteredTitle"); - FilteredEmptyState.Subtitle = _res.GetString("EmptyFilteredSubtitle"); + EmptyState.Title = _resourceLoader.GetString("EmptyNotesTitle"); + EmptyState.Subtitle = _resourceLoader.GetString("EmptyNotesSubtitle"); + FilteredEmptyState.Title = _resourceLoader.GetString("EmptyFilteredTitle"); + FilteredEmptyState.Subtitle = _resourceLoader.GetString("EmptyFilteredSubtitle"); await vm.LoadEntriesCommand.ExecuteAsync(null); UpdateList(); @@ -310,18 +311,7 @@ private void NotesList_ContainerContentChanging(ListViewBase sender, // --- Toast conferma salvataggio --- - public void ShowSavedToast() - { - SavedTip.IsOpen = true; - Announce("Nota salvata."); - var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) }; - timer.Tick += (s, _) => - { - SavedTip.IsOpen = false; - ((DispatcherTimer)s!).Stop(); - }; - timer.Start(); - } + public void ShowSavedToast() => ListViewHelpers.ShowSavedToast(SavedTip, () => Announce("Nota salvata.")); // --- Helpers --- diff --git a/src/PassKey.Desktop/Views/SettingsView.xaml.cs b/src/PassKey.Desktop/Views/SettingsView.xaml.cs index d6c0827..014b759 100644 --- a/src/PassKey.Desktop/Views/SettingsView.xaml.cs +++ b/src/PassKey.Desktop/Views/SettingsView.xaml.cs @@ -13,7 +13,7 @@ public sealed partial class SettingsView : UserControl { private SettingsViewModel? _viewModel; private bool _updatingFromVm; - private readonly ResourceLoader _res = new(); + private readonly ResourceLoader _resourceLoader = new(); private IDialogQueueService _dialogQueue = null!; /// Raised when the user clicks "Guida e scorciatoie" to navigate to HelpView. @@ -143,21 +143,21 @@ private async void ChangePwButton_Click(object sender, RoutedEventArgs e) var currentPwBox = new SecureInputBox { - PlaceholderText = _res.GetString("ChangePwCurrentPlaceholder"), + PlaceholderText = _resourceLoader.GetString("ChangePwCurrentPlaceholder"), ShowRevealButton = Visibility.Visible, Width = 320 }; var newPwBox = new SecureInputBox { - PlaceholderText = _res.GetString("ChangePwNewPlaceholder"), + PlaceholderText = _resourceLoader.GetString("ChangePwNewPlaceholder"), ShowRevealButton = Visibility.Visible, Width = 320 }; var confirmPwBox = new SecureInputBox { - PlaceholderText = _res.GetString("ChangePwConfirmPlaceholder"), + PlaceholderText = _resourceLoader.GetString("ChangePwConfirmPlaceholder"), ShowRevealButton = Visibility.Visible, Width = 320 }; @@ -178,10 +178,10 @@ private async void ChangePwButton_Click(object sender, RoutedEventArgs e) var dialog = new ContentDialog { - Title = _res.GetString("ChangePwDialogTitle"), + Title = _resourceLoader.GetString("ChangePwDialogTitle"), Content = panel, - PrimaryButtonText = _res.GetString("ChangePwDialogPrimary"), - CloseButtonText = _res.GetString("ChangePwDialogClose"), + PrimaryButtonText = _resourceLoader.GetString("ChangePwDialogPrimary"), + CloseButtonText = _resourceLoader.GetString("ChangePwDialogClose"), DefaultButton = ContentDialogButton.Close, XamlRoot = XamlRoot }; @@ -196,13 +196,13 @@ private async void ChangePwButton_Click(object sender, RoutedEventArgs e) string? error = null; if (string.IsNullOrEmpty(currentPw)) - error = _res.GetString("ChangePwErrEmpty"); + error = _resourceLoader.GetString("ChangePwErrEmpty"); else if (newPw.Length < 8) - error = _res.GetString("ChangePwErrShort"); + error = _resourceLoader.GetString("ChangePwErrShort"); else if (newPw != confirmPw) - error = _res.GetString("ChangePwErrMismatch"); + error = _resourceLoader.GetString("ChangePwErrMismatch"); else if (newPw == currentPw) - error = _res.GetString("ChangePwErrSame"); + error = _resourceLoader.GetString("ChangePwErrSame"); if (error is not null) { @@ -226,14 +226,14 @@ private async void ChangePwButton_Click(object sender, RoutedEventArgs e) if (success) { await ShowInfoDialogAsync( - _res.GetString("ChangePwSuccessTitle"), - _res.GetString("ChangePwSuccessMessage")); + _resourceLoader.GetString("ChangePwSuccessTitle"), + _resourceLoader.GetString("ChangePwSuccessMessage")); } else { await ShowInfoDialogAsync( - _res.GetString("ChangePwErrorTitle"), - _res.GetString("ChangePwErrorMessage")); + _resourceLoader.GetString("ChangePwErrorTitle"), + _resourceLoader.GetString("ChangePwErrorMessage")); } } finally @@ -283,11 +283,11 @@ private async void CheckUpdateButton_Click(object sender, RoutedEventArgs e) private string FormatLastCheckTime(DateTime? utc) { - if (!utc.HasValue) return _res.GetString("UpdateNeverChecked"); + if (!utc.HasValue) return _resourceLoader.GetString("UpdateNeverChecked"); var diff = DateTime.Now - utc.Value.ToLocalTime(); - if (diff.TotalMinutes < 1) return _res.GetString("UpdateJustChecked"); - if (diff.TotalHours < 1) return string.Format(_res.GetString("UpdateCheckedMinutesAgo"), (int)diff.TotalMinutes); - if (diff.TotalDays < 1) return string.Format(_res.GetString("UpdateCheckedHoursAgo"), (int)diff.TotalHours); + if (diff.TotalMinutes < 1) return _resourceLoader.GetString("UpdateJustChecked"); + if (diff.TotalHours < 1) return string.Format(_resourceLoader.GetString("UpdateCheckedMinutesAgo"), (int)diff.TotalMinutes); + if (diff.TotalDays < 1) return string.Format(_resourceLoader.GetString("UpdateCheckedHoursAgo"), (int)diff.TotalHours); return utc.Value.ToLocalTime().ToString("d MMM yyyy, HH:mm"); } @@ -321,13 +321,13 @@ private async void ImportButton_Click(object sender, RoutedEventArgs e) { var pwBox = new SecureInputBox { - PlaceholderText = _res.GetString("BackupPwPlaceholder"), + PlaceholderText = _resourceLoader.GetString("BackupPwPlaceholder"), ShowRevealButton = Visibility.Visible, Width = 320 }; var confirmBox = new SecureInputBox { - PlaceholderText = _res.GetString("BackupPwConfirmPlaceholder"), + PlaceholderText = _resourceLoader.GetString("BackupPwConfirmPlaceholder"), ShowRevealButton = Visibility.Visible, Width = 320 }; @@ -341,7 +341,7 @@ private async void ImportButton_Click(object sender, RoutedEventArgs e) var descText = new TextBlock { - Text = _res.GetString("BackupPwDialogDesc"), + Text = _resourceLoader.GetString("BackupPwDialogDesc"), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 12) }; @@ -354,10 +354,10 @@ private async void ImportButton_Click(object sender, RoutedEventArgs e) var dialog = new ContentDialog { - Title = _res.GetString("BackupPwDialogTitle"), + Title = _resourceLoader.GetString("BackupPwDialogTitle"), Content = panel, PrimaryButtonText = "OK", - CloseButtonText = _res.GetString("RestoreWarningCancel"), + CloseButtonText = _resourceLoader.GetString("RestoreWarningCancel"), DefaultButton = ContentDialogButton.Primary, XamlRoot = XamlRoot }; @@ -366,11 +366,11 @@ private async void ImportButton_Click(object sender, RoutedEventArgs e) { string? error = null; if (string.IsNullOrEmpty(pwBox.Password)) - error = _res.GetString("BackupPwErrEmpty"); + error = _resourceLoader.GetString("BackupPwErrEmpty"); else if (pwBox.Password.Length < 8) - error = _res.GetString("BackupPwErrShort"); + error = _resourceLoader.GetString("BackupPwErrShort"); else if (pwBox.Password != confirmBox.Password) - error = _res.GetString("BackupPwErrMismatch"); + error = _resourceLoader.GetString("BackupPwErrMismatch"); if (error is not null) { @@ -390,10 +390,10 @@ private async Task OnRestoreWarningRequested() { var dialog = new ContentDialog { - Title = _res.GetString("RestoreWarningTitle"), - Content = _res.GetString("RestoreWarningMessage"), - PrimaryButtonText = _res.GetString("RestoreWarningConfirm"), - CloseButtonText = _res.GetString("RestoreWarningCancel"), + Title = _resourceLoader.GetString("RestoreWarningTitle"), + Content = _resourceLoader.GetString("RestoreWarningMessage"), + PrimaryButtonText = _resourceLoader.GetString("RestoreWarningConfirm"), + CloseButtonText = _resourceLoader.GetString("RestoreWarningCancel"), DefaultButton = ContentDialogButton.Close, XamlRoot = XamlRoot }; @@ -405,17 +405,17 @@ private async Task OnRestoreWarningRequested() { var pwBox = new SecureInputBox { - PlaceholderText = _res.GetString("RestorePwPlaceholder"), + PlaceholderText = _resourceLoader.GetString("RestorePwPlaceholder"), ShowRevealButton = Visibility.Visible, Width = 320 }; var dialog = new ContentDialog { - Title = _res.GetString("RestorePwDialogTitle"), + Title = _resourceLoader.GetString("RestorePwDialogTitle"), Content = pwBox, PrimaryButtonText = "OK", - CloseButtonText = _res.GetString("RestoreWarningCancel"), + CloseButtonText = _resourceLoader.GetString("RestoreWarningCancel"), DefaultButton = ContentDialogButton.Primary, XamlRoot = XamlRoot }; @@ -429,18 +429,18 @@ private async Task OnRestoreWarningRequested() private async Task<(ImportFormat format, bool confirmed)> OnImportFormatRequested() { var combo = new ComboBox { Width = 320 }; - combo.Items.Add(new ComboBoxItem { Content = _res.GetString("ImportFormatCsv"), Tag = ImportFormat.Csv }); - combo.Items.Add(new ComboBoxItem { Content = _res.GetString("ImportFormatKdbx"), Tag = ImportFormat.Kdbx }); - combo.Items.Add(new ComboBoxItem { Content = _res.GetString("ImportFormatOnePux"), Tag = ImportFormat.OnePux }); - combo.Items.Add(new ComboBoxItem { Content = _res.GetString("ImportFormatBitwarden"), Tag = ImportFormat.Bitwarden }); + combo.Items.Add(new ComboBoxItem { Content = _resourceLoader.GetString("ImportFormatCsv"), Tag = ImportFormat.Csv }); + combo.Items.Add(new ComboBoxItem { Content = _resourceLoader.GetString("ImportFormatKdbx"), Tag = ImportFormat.Kdbx }); + combo.Items.Add(new ComboBoxItem { Content = _resourceLoader.GetString("ImportFormatOnePux"), Tag = ImportFormat.OnePux }); + combo.Items.Add(new ComboBoxItem { Content = _resourceLoader.GetString("ImportFormatBitwarden"), Tag = ImportFormat.Bitwarden }); combo.SelectedIndex = 0; var dialog = new ContentDialog { - Title = _res.GetString("ImportFormatDialogTitle"), + Title = _resourceLoader.GetString("ImportFormatDialogTitle"), Content = combo, PrimaryButtonText = "OK", - CloseButtonText = _res.GetString("RestoreWarningCancel"), + CloseButtonText = _resourceLoader.GetString("RestoreWarningCancel"), DefaultButton = ContentDialogButton.Primary, XamlRoot = XamlRoot }; @@ -458,17 +458,17 @@ private async Task OnRestoreWarningRequested() { var pwBox = new SecureInputBox { - PlaceholderText = _res.GetString("ImportKdbxPwPlaceholder"), + PlaceholderText = _resourceLoader.GetString("ImportKdbxPwPlaceholder"), ShowRevealButton = Visibility.Visible, Width = 320 }; var dialog = new ContentDialog { - Title = _res.GetString("ImportKdbxPwTitle"), + Title = _resourceLoader.GetString("ImportKdbxPwTitle"), Content = pwBox, PrimaryButtonText = "OK", - CloseButtonText = _res.GetString("RestoreWarningCancel"), + CloseButtonText = _resourceLoader.GetString("RestoreWarningCancel"), DefaultButton = ContentDialogButton.Primary, XamlRoot = XamlRoot }; @@ -488,15 +488,15 @@ private async Task OnRestoreWarningRequested() Margin = new Thickness(0, 0, 0, 16) }; var lines = new System.Text.StringBuilder(); - if (pwCount > 0) lines.AppendLine(string.Format(_res.GetString("ImportSummaryPasswords"), pwCount)); - if (cardCount > 0) lines.AppendLine(string.Format(_res.GetString("ImportSummaryCards"), cardCount)); - if (idCount > 0) lines.AppendLine(string.Format(_res.GetString("ImportSummaryIdentities"), idCount)); - if (noteCount > 0) lines.AppendLine(string.Format(_res.GetString("ImportSummaryNotes"), noteCount)); + if (pwCount > 0) lines.AppendLine(string.Format(_resourceLoader.GetString("ImportSummaryPasswords"), pwCount)); + if (cardCount > 0) lines.AppendLine(string.Format(_resourceLoader.GetString("ImportSummaryCards"), cardCount)); + if (idCount > 0) lines.AppendLine(string.Format(_resourceLoader.GetString("ImportSummaryIdentities"), idCount)); + if (noteCount > 0) lines.AppendLine(string.Format(_resourceLoader.GetString("ImportSummaryNotes"), noteCount)); summaryText.Text = lines.ToString().TrimEnd(); - var skipRadio = new RadioButton { Content = _res.GetString("ImportMergeSkip"), IsChecked = true }; - var overwriteRadio = new RadioButton { Content = _res.GetString("ImportMergeOverwrite") }; - var keepBothRadio = new RadioButton { Content = _res.GetString("ImportMergeKeepBoth") }; + var skipRadio = new RadioButton { Content = _resourceLoader.GetString("ImportMergeSkip"), IsChecked = true }; + var overwriteRadio = new RadioButton { Content = _resourceLoader.GetString("ImportMergeOverwrite") }; + var keepBothRadio = new RadioButton { Content = _resourceLoader.GetString("ImportMergeKeepBoth") }; var panel = new StackPanel { Spacing = 8 }; panel.Children.Add(summaryText); @@ -506,10 +506,10 @@ private async Task OnRestoreWarningRequested() var dialog = new ContentDialog { - Title = _res.GetString("ImportMergeTitle"), + Title = _resourceLoader.GetString("ImportMergeTitle"), Content = panel, PrimaryButtonText = "OK", - CloseButtonText = _res.GetString("RestoreWarningCancel"), + CloseButtonText = _resourceLoader.GetString("RestoreWarningCancel"), DefaultButton = ContentDialogButton.Primary, XamlRoot = XamlRoot }; @@ -529,37 +529,37 @@ private async Task OnRestoreWarningRequested() private async Task OnImportCompleted(ImportResult result) { var lines = new System.Text.StringBuilder(); - if (result.PasswordsImported > 0) lines.AppendLine(string.Format(_res.GetString("ImportSummaryPasswords"), result.PasswordsImported)); - if (result.CardsImported > 0) lines.AppendLine(string.Format(_res.GetString("ImportSummaryCards"), result.CardsImported)); - if (result.IdentitiesImported > 0) lines.AppendLine(string.Format(_res.GetString("ImportSummaryIdentities"), result.IdentitiesImported)); - if (result.NotesImported > 0) lines.AppendLine(string.Format(_res.GetString("ImportSummaryNotes"), result.NotesImported)); - if (result.Skipped > 0) lines.AppendLine(string.Format(_res.GetString("ImportSummarySkipped"), result.Skipped)); + if (result.PasswordsImported > 0) lines.AppendLine(string.Format(_resourceLoader.GetString("ImportSummaryPasswords"), result.PasswordsImported)); + if (result.CardsImported > 0) lines.AppendLine(string.Format(_resourceLoader.GetString("ImportSummaryCards"), result.CardsImported)); + if (result.IdentitiesImported > 0) lines.AppendLine(string.Format(_resourceLoader.GetString("ImportSummaryIdentities"), result.IdentitiesImported)); + if (result.NotesImported > 0) lines.AppendLine(string.Format(_resourceLoader.GetString("ImportSummaryNotes"), result.NotesImported)); + if (result.Skipped > 0) lines.AppendLine(string.Format(_resourceLoader.GetString("ImportSummarySkipped"), result.Skipped)); - await ShowInfoDialogAsync(_res.GetString("ImportSummaryTitle"), lines.ToString().TrimEnd()); + await ShowInfoDialogAsync(_resourceLoader.GetString("ImportSummaryTitle"), lines.ToString().TrimEnd()); } private async Task OnBackupCompleted(string path) { await ShowInfoDialogAsync( - _res.GetString("BackupSuccessTitle"), - _res.GetString("BackupSuccessMessage")); + _resourceLoader.GetString("BackupSuccessTitle"), + _resourceLoader.GetString("BackupSuccessMessage")); } private async Task OnRestoreCompleted() { await ShowInfoDialogAsync( - _res.GetString("RestoreSuccessTitle"), - _res.GetString("RestoreSuccessMessage")); + _resourceLoader.GetString("RestoreSuccessTitle"), + _resourceLoader.GetString("RestoreSuccessMessage")); } private async Task OnOperationError(string errorCode) { var message = errorCode switch { - "WRONG_PASSWORD" => _res.GetString("RestoreErrorWrongPw"), - "INVALID_FILE" => _res.GetString("RestoreErrorInvalid"), + "WRONG_PASSWORD" => _resourceLoader.GetString("RestoreErrorWrongPw"), + "INVALID_FILE" => _resourceLoader.GetString("RestoreErrorInvalid"), _ => errorCode }; - await ShowInfoDialogAsync(_res.GetString("ImportErrorTitle"), message); + await ShowInfoDialogAsync(_resourceLoader.GetString("ImportErrorTitle"), message); } } From 8f778cdd7cbcc9f721493f23a7c4f9e5ffd62e20 Mon Sep 17 00:00:00 2001 From: Giuseppe Imperato Date: Sun, 10 May 2026 18:27:31 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20browser=20extension=20store=20readi?= =?UTF-8?q?ness=20=E2=80=94=20P0/P1=20fixes=20+=20privacy=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome: - Add CSP, author, homepage_url, full description, tabs permission - Fix content_scripts matches (https/http only) - Replace innerHTML SVG injection with DOMParser (CSP compliance) - Fix stale tab ID in onFill() — re-query active tab at fill time - Add rate limiting on Retry button - Set document.lang dynamically from navigator.language Firefox: - Add tabs permission, host_permissions, Gecko UUID for AMO - Same SVG/tab/retry/lang fixes as Chrome Both: - Add parseResponse() validation in all background.js handlers - Fix French gender typo in i18n.js (Aucune → Aucun) - Fix language fallback: Italian → English - Remove hardcoded lang="it" from popup.html Native Messaging: - Update Firefox extension ID from dev placeholder to AMO UUID - Split build-installer.ps1 into separate Chrome + Firefox manifests Privacy Policy: - Add docs/privacy/index.html (GitHub Pages: /PassKey/privacy) - Update docs/privacy-policy.md: add tabs permission, update date Co-Authored-By: Claude Sonnet 4.6 --- docs/privacy-policy.md | 3 +- docs/privacy/index.html | 343 ++++++++++++++++++ extensions/chrome/background.js | 14 +- extensions/chrome/lib/i18n.js | 8 +- extensions/chrome/manifest.json | 12 +- extensions/chrome/popup/popup.html | 2 +- extensions/chrome/popup/popup.js | 51 ++- extensions/firefox/background.js | 14 +- extensions/firefox/lib/i18n.js | 8 +- extensions/firefox/manifest.json | 12 +- extensions/firefox/popup/popup.html | 2 +- extensions/firefox/popup/popup.js | 44 ++- scripts/build-installer.ps1 | 24 +- .../Manifests/com.passkey.host.firefox.json | 2 +- .../NativeMessagingRegistrationService.cs | 3 +- 15 files changed, 477 insertions(+), 65 deletions(-) create mode 100644 docs/privacy/index.html diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md index 0b9a1b2..be04119 100644 --- a/docs/privacy-policy.md +++ b/docs/privacy-policy.md @@ -1,6 +1,6 @@ # PassKey Privacy Policy -**Last updated:** 2026-03-15 +**Last updated:** 2026-05-10 --- @@ -39,6 +39,7 @@ The extension requests only the minimum permissions required: |------------|---------| | `nativeMessaging` | Communicate with PassKey Desktop via Native Messaging | | `activeTab` | Read the current tab's URL to match credentials | +| `tabs` | Inject autofill into the active tab and keep the popup's tab reference current | --- diff --git a/docs/privacy/index.html b/docs/privacy/index.html new file mode 100644 index 0000000..b87d85c --- /dev/null +++ b/docs/privacy/index.html @@ -0,0 +1,343 @@ + + + + + + Privacy Policy — PassKey + + + + +
+ + PassKey +
+ +
+ +
Privacy Policy
+

Your data stays on your device. Always.

+

Last updated: May 10, 2026  ·  Applies to PassKey Desktop and Browser Extension

+ +
+ TL;DR +

PassKey never sends your data anywhere. No cloud, no servers, no analytics, no telemetry. + Everything stays encrypted on your computer.

+
+ + +
+

1. Overview

+

PassKey is a local-first password manager for Windows. The desktop application and its + browser extension store and manage your credentials exclusively on your device. + No account is required to use PassKey. No data is ever transmitted to any remote server + — by design, there is no remote server to transmit data to.

+

This policy describes what information PassKey reads or processes while running on + your computer and how that information is used.

+
+ + +
+

2. Data Storage

+
    +
  • All vault data (passwords, credit cards, identities, secure notes) is stored in an + encrypted SQLite database on your local disk.
  • +
  • Default location: %LOCALAPPDATA%\PassKey\vault.db
  • +
  • Encryption: AES-256-GCM, keys derived from your master password via + Argon2id (or PBKDF2-SHA256 for vaults created on older versions).
  • +
  • Your master password is never persisted — it is held in memory only for the duration + it is needed to derive the decryption key, then zeroed.
  • +
+
+ + +
+

3. Network Activity

+

PassKey makes zero outbound network connections. The only communication + that occurs is between the browser extension and the PassKey Desktop application on your + own computer, via the browser's Native Messaging API over a local Named Pipe:

+
    +
  • This communication never leaves your machine.
  • +
  • The channel is protected with ephemeral ECDH P-256 + AES-256-GCM + session keys negotiated at runtime.
  • +
+

There is no analytics, no telemetry, no crash reporting, no update checking, + and no advertising — not now, not ever.

+
+ + +
+

4. Browser Extension Permissions

+

The PassKey browser extension (available for Chrome, Edge, and Firefox) requests the + minimum permissions necessary to operate. Below is a complete list of what each + permission is used for:

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
PermissionWhy it is neededWhat it accesses
nativeMessagingCommunicate with PassKey Desktop via the browser's Native Messaging APILocal IPC channel to PassKey Desktop — no internet access
activeTabRead the URL of the current tab to find matching credentialsURL only — no page content, no cookies, no form data
tabsInject autofill into the active tab and keep the popup's tab reference currentActive tab ID and URL — no browsing history
+
+

The extension reads the URL of the tab you are currently + viewing solely to identify which saved credentials match the site. This URL is passed + to the local PassKey Desktop app for matching and is never stored by the extension or + sent anywhere else.

+
+ + +
+

5. Data Sharing

+

PassKey does not share any data with third parties. There are no third-party SDKs, + advertising networks, or analytics providers embedded in PassKey. There is no data + to share because no data leaves your device.

+
+ + +
+

6. Backups

+

Encrypted backups (.pkbak files) are stored locally at a location you + choose. Backups are independently encrypted with AES-256-GCM using an Argon2id-derived + key from a password you provide at backup time. PassKey does not offer or access any + cloud backup service.

+
+ + +
+

7. Open Source & Auditability

+

PassKey is open-source software licensed under the + GNU GPL v3. + The complete source code is publicly available. You can audit every line of code that + handles your data at + github.com/pexatar/PassKey.

+
+ + +
+

8. Changes to This Policy

+

If this policy is updated, the new version will be published at this URL with an + updated date at the top. Because PassKey collects no personal data, changes will + typically only reflect new features or clarifications to existing practices.

+
+ + +
+

9. Contact

+ +
+ +
+ + + + + diff --git a/extensions/chrome/background.js b/extensions/chrome/background.js index 8d6f28c..070985d 100644 --- a/extensions/chrome/background.js +++ b/extensions/chrome/background.js @@ -221,7 +221,7 @@ async function handleMessage(msg, sender) { async function handleGetStatus() { try { const req = buildRequest('get-status'); - const resp = await sendNativeMessage(req); + const resp = parseResponse(await sendNativeMessage(req)); return resp; } catch (err) { return { success: false, error: err.message || 'desktop-not-running' }; @@ -238,7 +238,7 @@ async function handleGetStatus() { async function handleGetCredentials(url) { try { const req = buildRequest('get-credentials', { url }); - const resp = await sendNativeMessage(req); + const resp = parseResponse(await sendNativeMessage(req)); return resp; } catch (err) { return { success: false, error: err.message || 'desktop-not-running' }; @@ -253,7 +253,7 @@ async function handleGetCredentials(url) { async function handleGetAllCredentials() { try { const req = buildRequest('get-all-credentials'); - const resp = await sendNativeMessage(req); + const resp = parseResponse(await sendNativeMessage(req)); return resp; } catch (err) { return { success: false, error: err.message || 'desktop-not-running' }; @@ -272,7 +272,7 @@ async function handleCopyCredential(credentialId) { try { await ensureSession(); const req = buildRequest('get-credential-password', { id: credentialId }); - const resp = await sendNativeMessage(req); + const resp = parseResponse(await sendNativeMessage(req)); if (!resp.success) return resp; let password; @@ -297,7 +297,7 @@ async function handleCopyCredential(credentialId) { async function handleUnlockVault(masterPassword) { try { const req = buildRequest('unlock-vault', { masterPassword }); - const resp = await sendNativeMessage(req); + const resp = parseResponse(await sendNativeMessage(req)); return resp; } catch (err) { return { success: false, error: err.message || 'unlock-failed' }; @@ -312,7 +312,7 @@ async function handleUnlockVault(masterPassword) { async function handleShowWindow() { try { const req = buildRequest('show-window'); - const resp = await sendNativeMessage(req); + const resp = parseResponse(await sendNativeMessage(req)); return resp; } catch (err) { return { success: false, error: err.message || 'show-window-failed' }; @@ -371,7 +371,7 @@ async function handleFillCredential(credentialId, username, tabId) { // Request encrypted password const req = buildRequest('get-credential-password', { id: credentialId }); - const resp = await sendNativeMessage(req); + const resp = parseResponse(await sendNativeMessage(req)); if (!resp.success) { return resp; diff --git a/extensions/chrome/lib/i18n.js b/extensions/chrome/lib/i18n.js index c62d2e9..cf492b6 100644 --- a/extensions/chrome/lib/i18n.js +++ b/extensions/chrome/lib/i18n.js @@ -103,7 +103,7 @@ const STRINGS = { unlockBtn: 'Déverrouiller', wrongPassword: 'Mot de passe incorrect. Réessayez.', unlocking: 'Déverrouillage...', - emptyTitle: 'Aucune identifiant', + emptyTitle: 'Aucun identifiant', emptySub: 'Aucun identifiant enregistré.', loadingText: 'Connexion...', tabThisSite: 'Ce site', @@ -215,6 +215,6 @@ const STRINGS = { }; // Resolve the two-character primary language subtag from the browser locale. -// Falls back to Italian ('it') for unsupported locales. -const lang = (navigator.language || 'it').slice(0, 2).toLowerCase(); -window.t = STRINGS[lang] || STRINGS['it']; +// Falls back to English ('en') for unsupported locales. +const lang = (navigator.language || 'en').slice(0, 2).toLowerCase(); +window.t = STRINGS[lang] || STRINGS['en']; diff --git a/extensions/chrome/manifest.json b/extensions/chrome/manifest.json index d9357b2..5a524d3 100644 --- a/extensions/chrome/manifest.json +++ b/extensions/chrome/manifest.json @@ -2,18 +2,24 @@ "manifest_version": 3, "name": "PassKey", "version": "1.0.0", + "author": "Giuseppe Imperato", + "homepage_url": "https://github.com/pexatar/PassKey", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4pcZ6gFcMwryE8/OgBRW0lFn7a8kad6/J84GostBKfqf1at5RVaGW31arX54L+usiJ4EUizkwaTvUKKtoWaxyUYJ3nOxPNfrRysoJQwjP50IBQdOnR7VXqcKzO+L8xvfcdsK4CHsyTX7bU1Q/xoeawR9Or5yVKSPhNy86A63Qa2z2Y2QIyCB713jrElycb2sOsK/szEkIpG5teJCg/NA1nZ1VQXMu8j+kROaKNL0atpZAUoef2EGLU6uZK46es+HyvGXDZk6ZiQoey3FPhz5iMAd2ATS7Ml+9+TuTSE+tvtmF2swWI8SvWBshxa5a7y/UGE2Qqnr/XWY1eNLhP4MuQIDAQAB", - "description": "PassKey Password Manager - Autofill Extension", + "description": "Local password manager integration for PassKey desktop app. Autofill credentials, credit cards and identities stored securely on your PC — no cloud, no subscription.", "permissions": [ "nativeMessaging", - "activeTab" + "activeTab", + "tabs" ], + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'none';" + }, "background": { "service_worker": "background.js" }, "content_scripts": [ { - "matches": [""], + "matches": ["https://*/*", "http://*/*"], "js": ["lib/url-utils.js", "lib/messages.js", "content.js"], "run_at": "document_idle", "all_frames": false diff --git a/extensions/chrome/popup/popup.html b/extensions/chrome/popup/popup.html index e11c509..b85d287 100644 --- a/extensions/chrome/popup/popup.html +++ b/extensions/chrome/popup/popup.html @@ -1,5 +1,5 @@ - + diff --git a/extensions/chrome/popup/popup.js b/extensions/chrome/popup/popup.js index 1006f68..136e756 100644 --- a/extensions/chrome/popup/popup.js +++ b/extensions/chrome/popup/popup.js @@ -39,6 +39,21 @@ const SPIN_SVG = ` `; +// ─── SVG helper (CSP-safe: avoids .innerHTML) ──────────────────────────────── + +/** + * Parses an SVG string and appends the resulting SVG element as the sole child + * of the given element. Uses DOMParser instead of .innerHTML to comply with + * the extension's Content Security Policy (script-src 'self'). + * + * @param {Element} element - The container element to update. + * @param {string} svgString - Raw SVG markup string. + */ +function setSvgIcon(element, svgString) { + const doc = new DOMParser().parseFromString(svgString, 'image/svg+xml'); + element.replaceChildren(doc.documentElement); +} + // ─── State ──────────────────────────────────────────────────────────────────── const STATES = ['loading', 'disconnected', 'unlock', 'empty', 'list']; @@ -85,6 +100,9 @@ const footerVersion = $('footer-version'); */ function initStrings() { const t = window.t; + // Set lang attribute dynamically so screen readers and CSS :lang() rules + // reflect the actual locale (popup.html defaults to 'en' as static fallback). + document.documentElement.lang = (navigator.language || 'en').slice(0, 2).toLowerCase(); loadingText.textContent = t.loadingText; disconnectedTitle.textContent = t.disconnectedTitle; disconnectedSub.textContent = t.disconnectedSub; @@ -283,7 +301,7 @@ tabAll.addEventListener('click', () => switchView('all')); * @param {Array<{id: string, title: string, username: string, hasPassword: boolean}>} creds */ function renderList(creds) { - credList.innerHTML = ''; + credList.replaceChildren(); for (const cred of creds) { credList.appendChild(buildItem(cred)); } @@ -345,7 +363,7 @@ function buildItem(cred) { const pwIndicator = document.createElement('span'); pwIndicator.className = 'pk-pw-indicator'; if (window.t.hasPassword) pwIndicator.setAttribute('aria-label', window.t.hasPassword); - pwIndicator.innerHTML = LOCK_SM_SVG; + setSvgIcon(pwIndicator, LOCK_SM_SVG); li.appendChild(avatar); li.appendChild(body); @@ -397,7 +415,7 @@ function makeIconBtn(svgHtml, label, action) { btn.setAttribute('data-action', action); btn.setAttribute('tabindex', '-1'); btn.type = 'button'; - btn.innerHTML = svgHtml; + setSvgIcon(btn, svgHtml); return btn; } @@ -422,7 +440,7 @@ function makeActionBtn(svgHtml, label, action) { const icon = document.createElement('span'); icon.className = 'pk-action-icon'; icon.setAttribute('aria-hidden', 'true'); - icon.innerHTML = svgHtml; + setSvgIcon(icon, svgHtml); const text = document.createElement('span'); text.className = 'pk-action-label'; @@ -459,7 +477,7 @@ async function onCopyUsername(cred, btn) { */ async function onCopyPassword(cred, btn) { const iconTarget = btn.querySelector('.pk-action-icon') ?? btn; - iconTarget.innerHTML = SPIN_SVG; + setSvgIcon(iconTarget, SPIN_SVG); btn.disabled = true; try { @@ -485,15 +503,19 @@ async function onCopyPassword(cred, btn) { async function onFill(cred, btn) { if (btn) { const iconTarget = btn.querySelector('.pk-action-icon') ?? btn; - iconTarget.innerHTML = SPIN_SVG; + setSvgIcon(iconTarget, SPIN_SVG); btn.disabled = true; } try { + // Re-query active tab at fill time to avoid stale tab ID (user may have + // switched tabs without closing the popup). + const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!activeTab?.id) { window.close(); return; } await chrome.runtime.sendMessage({ type: 'fill-credential', id: cred.id, username: cred.username, - tabId: activeTabId + tabId: activeTab.id }); } catch { /* ignore */ } window.close(); @@ -512,7 +534,7 @@ async function onFill(cred, btn) { function showBtnFeedback(btn, iconSvg, ok, isError = false, restoreIcon = null) { // Support both icon-only (.pk-icon-btn) and labeled (.pk-action-btn) buttons const iconTarget = btn.querySelector('.pk-action-icon') ?? btn; - iconTarget.innerHTML = iconSvg; + setSvgIcon(iconTarget, iconSvg); btn.classList.toggle('success', ok && !isError); btn.classList.toggle('error', isError); btn.disabled = false; @@ -521,7 +543,7 @@ function showBtnFeedback(btn, iconSvg, ok, isError = false, restoreIcon = null) copyFeedback.textContent = window.t[msgKey]; setTimeout(() => { - iconTarget.innerHTML = restoreIcon ?? (ok ? CHECK_SVG : iconSvg); + setSvgIcon(iconTarget, restoreIcon ?? (ok ? CHECK_SVG : iconSvg)); btn.classList.remove('success', 'error'); copyFeedback.textContent = ''; }, 1500); @@ -543,7 +565,7 @@ async function doUnlock() { if (!pw) return; unlockError.hidden = true; - btnUnlock.innerHTML = SPIN_SVG; + setSvgIcon(btnUnlock, SPIN_SVG); btnUnlock.disabled = true; let result; @@ -562,15 +584,18 @@ async function doUnlock() { } else { unlockError.textContent = window.t.wrongPassword; unlockError.hidden = false; - btnUnlock.innerHTML = window.t.unlockBtn; - btnUnlock.disabled = false; + btnUnlock.textContent = window.t.unlockBtn; + btnUnlock.disabled = false; pwInput.focus(); } } // ─── Retry / reconnect ──────────────────────────────────────────────────────── -btnRetry.addEventListener('click', init); +btnRetry.addEventListener('click', () => { + btnRetry.disabled = true; + init().finally(() => { btnRetry.disabled = false; }); +}); // ─── Search / filter ────────────────────────────────────────────────────────── diff --git a/extensions/firefox/background.js b/extensions/firefox/background.js index 0ce33ea..b5f1b5a 100644 --- a/extensions/firefox/background.js +++ b/extensions/firefox/background.js @@ -224,7 +224,7 @@ async function handleMessage(msg, sender) { async function handleGetStatus() { try { const req = buildRequest('get-status'); - const resp = await sendNativeMessage(req); + const resp = parseResponse(await sendNativeMessage(req)); return resp; } catch (err) { return { success: false, error: err.message || 'desktop-not-running' }; @@ -241,7 +241,7 @@ async function handleGetStatus() { async function handleGetCredentials(url) { try { const req = buildRequest('get-credentials', { url }); - const resp = await sendNativeMessage(req); + const resp = parseResponse(await sendNativeMessage(req)); return resp; } catch (err) { return { success: false, error: err.message || 'desktop-not-running' }; @@ -256,7 +256,7 @@ async function handleGetCredentials(url) { async function handleGetAllCredentials() { try { const req = buildRequest('get-all-credentials'); - const resp = await sendNativeMessage(req); + const resp = parseResponse(await sendNativeMessage(req)); return resp; } catch (err) { return { success: false, error: err.message || 'desktop-not-running' }; @@ -275,7 +275,7 @@ async function handleCopyCredential(credentialId) { try { await ensureSession(); const req = buildRequest('get-credential-password', { id: credentialId }); - const resp = await sendNativeMessage(req); + const resp = parseResponse(await sendNativeMessage(req)); if (!resp.success) return resp; let password; @@ -300,7 +300,7 @@ async function handleCopyCredential(credentialId) { async function handleUnlockVault(masterPassword) { try { const req = buildRequest('unlock-vault', { masterPassword }); - const resp = await sendNativeMessage(req); + const resp = parseResponse(await sendNativeMessage(req)); return resp; } catch (err) { return { success: false, error: err.message || 'unlock-failed' }; @@ -315,7 +315,7 @@ async function handleUnlockVault(masterPassword) { async function handleShowWindow() { try { const req = buildRequest('show-window'); - const resp = await sendNativeMessage(req); + const resp = parseResponse(await sendNativeMessage(req)); return resp; } catch (err) { return { success: false, error: err.message || 'show-window-failed' }; @@ -374,7 +374,7 @@ async function handleFillCredential(credentialId, username, tabId) { // Request encrypted password const req = buildRequest('get-credential-password', { id: credentialId }); - const resp = await sendNativeMessage(req); + const resp = parseResponse(await sendNativeMessage(req)); if (!resp.success) { return resp; diff --git a/extensions/firefox/lib/i18n.js b/extensions/firefox/lib/i18n.js index c62d2e9..cf492b6 100644 --- a/extensions/firefox/lib/i18n.js +++ b/extensions/firefox/lib/i18n.js @@ -103,7 +103,7 @@ const STRINGS = { unlockBtn: 'Déverrouiller', wrongPassword: 'Mot de passe incorrect. Réessayez.', unlocking: 'Déverrouillage...', - emptyTitle: 'Aucune identifiant', + emptyTitle: 'Aucun identifiant', emptySub: 'Aucun identifiant enregistré.', loadingText: 'Connexion...', tabThisSite: 'Ce site', @@ -215,6 +215,6 @@ const STRINGS = { }; // Resolve the two-character primary language subtag from the browser locale. -// Falls back to Italian ('it') for unsupported locales. -const lang = (navigator.language || 'it').slice(0, 2).toLowerCase(); -window.t = STRINGS[lang] || STRINGS['it']; +// Falls back to English ('en') for unsupported locales. +const lang = (navigator.language || 'en').slice(0, 2).toLowerCase(); +window.t = STRINGS[lang] || STRINGS['en']; diff --git a/extensions/firefox/manifest.json b/extensions/firefox/manifest.json index e84c7cc..bc77934 100644 --- a/extensions/firefox/manifest.json +++ b/extensions/firefox/manifest.json @@ -2,14 +2,18 @@ "manifest_version": 3, "name": "PassKey", "version": "1.0.0", - "description": "PassKey Password Manager - Autofill Extension", + "author": "Giuseppe Imperato", + "homepage_url": "https://github.com/pexatar/PassKey", + "description": "Local password manager integration for PassKey desktop app. Autofill credentials, credit cards and identities stored securely on your PC — no cloud, no subscription.", "permissions": [ "nativeMessaging", - "activeTab" + "activeTab", + "tabs" ], + "host_permissions": ["https://*/*", "http://*/*"], "browser_specific_settings": { "gecko": { - "id": "passkey@passkey.local", + "id": "{3E08FACC-D43B-4B20-89E7-7888F6082E9D}", "strict_min_version": "128.0" } }, @@ -18,7 +22,7 @@ }, "content_scripts": [ { - "matches": [""], + "matches": ["https://*/*", "http://*/*"], "js": ["lib/url-utils.js", "lib/messages.js", "content.js"], "run_at": "document_idle", "all_frames": false diff --git a/extensions/firefox/popup/popup.html b/extensions/firefox/popup/popup.html index e11c509..b85d287 100644 --- a/extensions/firefox/popup/popup.html +++ b/extensions/firefox/popup/popup.html @@ -1,5 +1,5 @@ - + diff --git a/extensions/firefox/popup/popup.js b/extensions/firefox/popup/popup.js index 121bb7e..0fead01 100644 --- a/extensions/firefox/popup/popup.js +++ b/extensions/firefox/popup/popup.js @@ -41,6 +41,20 @@ const SPIN_SVG = ` `; +// ─── CSP-safe SVG injection ─────────────────────────────────────────────────── + +/** + * Sets the SVG content of an element in a CSP-compliant way (no innerHTML). + * Parses the SVG string via DOMParser and replaces the element's children. + * + * @param {Element} element - Target element to receive the SVG. + * @param {string} svgString - SVG markup string. + */ +function setSvgIcon(element, svgString) { + const doc = new DOMParser().parseFromString(svgString, 'image/svg+xml'); + element.replaceChildren(doc.documentElement); +} + // ─── State ──────────────────────────────────────────────────────────────────── const STATES = ['loading', 'disconnected', 'unlock', 'empty', 'list']; @@ -103,6 +117,7 @@ function initStrings() { btnClear.setAttribute('aria-label', t.clearSearch); credList.setAttribute('aria-label', t.tabAll); btnOpenApp.textContent = t.openApp; + document.documentElement.lang = (navigator.language || 'en').slice(0, 2).toLowerCase(); } // ─── State machine ──────────────────────────────────────────────────────────── @@ -285,7 +300,7 @@ tabAll.addEventListener('click', () => switchView('all')); * @param {Array<{id: string, title: string, username: string, hasPassword: boolean}>} creds */ function renderList(creds) { - credList.innerHTML = ''; + credList.replaceChildren(); for (const cred of creds) { credList.appendChild(buildItem(cred)); } @@ -347,7 +362,7 @@ function buildItem(cred) { const pwIndicator = document.createElement('span'); pwIndicator.className = 'pk-pw-indicator'; if (window.t.hasPassword) pwIndicator.setAttribute('aria-label', window.t.hasPassword); - pwIndicator.innerHTML = LOCK_SM_SVG; + setSvgIcon(pwIndicator, LOCK_SM_SVG); li.appendChild(avatar); li.appendChild(body); @@ -399,7 +414,7 @@ function makeIconBtn(svgHtml, label, action) { btn.setAttribute('data-action', action); btn.setAttribute('tabindex', '-1'); btn.type = 'button'; - btn.innerHTML = svgHtml; + setSvgIcon(btn, svgHtml); return btn; } @@ -424,7 +439,7 @@ function makeActionBtn(svgHtml, label, action) { const icon = document.createElement('span'); icon.className = 'pk-action-icon'; icon.setAttribute('aria-hidden', 'true'); - icon.innerHTML = svgHtml; + setSvgIcon(icon, svgHtml); const text = document.createElement('span'); text.className = 'pk-action-label'; @@ -461,7 +476,7 @@ async function onCopyUsername(cred, btn) { */ async function onCopyPassword(cred, btn) { const iconTarget = btn.querySelector('.pk-action-icon') ?? btn; - iconTarget.innerHTML = SPIN_SVG; + setSvgIcon(iconTarget, SPIN_SVG); btn.disabled = true; try { @@ -487,15 +502,17 @@ async function onCopyPassword(cred, btn) { async function onFill(cred, btn) { if (btn) { const iconTarget = btn.querySelector('.pk-action-icon') ?? btn; - iconTarget.innerHTML = SPIN_SVG; + setSvgIcon(iconTarget, SPIN_SVG); btn.disabled = true; } try { + const [activeTab] = await browser.tabs.query({ active: true, currentWindow: true }); + if (!activeTab?.id) { window.close(); return; } await browser.runtime.sendMessage({ type: 'fill-credential', id: cred.id, username: cred.username, - tabId: activeTabId + tabId: activeTab.id }); } catch { /* ignore */ } window.close(); @@ -514,7 +531,7 @@ async function onFill(cred, btn) { function showBtnFeedback(btn, iconSvg, ok, isError = false, restoreIcon = null) { // Support both icon-only (.pk-icon-btn) and labeled (.pk-action-btn) buttons const iconTarget = btn.querySelector('.pk-action-icon') ?? btn; - iconTarget.innerHTML = iconSvg; + setSvgIcon(iconTarget, iconSvg); btn.classList.toggle('success', ok && !isError); btn.classList.toggle('error', isError); btn.disabled = false; @@ -523,7 +540,7 @@ function showBtnFeedback(btn, iconSvg, ok, isError = false, restoreIcon = null) copyFeedback.textContent = window.t[msgKey]; setTimeout(() => { - iconTarget.innerHTML = restoreIcon ?? (ok ? CHECK_SVG : iconSvg); + setSvgIcon(iconTarget, restoreIcon ?? (ok ? CHECK_SVG : iconSvg)); btn.classList.remove('success', 'error'); copyFeedback.textContent = ''; }, 1500); @@ -545,7 +562,7 @@ async function doUnlock() { if (!pw) return; unlockError.hidden = true; - btnUnlock.innerHTML = SPIN_SVG; + setSvgIcon(btnUnlock, SPIN_SVG); btnUnlock.disabled = true; let result; @@ -564,7 +581,7 @@ async function doUnlock() { } else { unlockError.textContent = window.t.wrongPassword; unlockError.hidden = false; - btnUnlock.innerHTML = window.t.unlockBtn; + btnUnlock.textContent = window.t.unlockBtn; btnUnlock.disabled = false; pwInput.focus(); } @@ -572,7 +589,10 @@ async function doUnlock() { // ─── Retry / reconnect ──────────────────────────────────────────────────────── -btnRetry.addEventListener('click', init); +btnRetry.addEventListener('click', () => { + btnRetry.disabled = true; + init().finally(() => { btnRetry.disabled = false; }); +}); // ─── Search / filter ────────────────────────────────────────────────────────── diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1 index 32881b5..80381ba 100644 --- a/scripts/build-installer.ps1 +++ b/scripts/build-installer.ps1 @@ -52,22 +52,34 @@ dotnet publish "$RepoRoot\src\PassKey.BrowserHost\PassKey.BrowserHost.csproj" ` -o $publishPath if ($LASTEXITCODE -ne 0) { throw "BrowserHost publish failed" } -# 3. Generate Native Messaging Host manifest -Write-Host "Generating NMH manifest..." -ForegroundColor Green -$nmhManifest = @{ +# 3. Generate Native Messaging Host manifest (reference copy bundled with installer) +# NOTE: The authoritative manifests are generated at runtime by NativeMessagingRegistrationService +# into %LOCALAPPDATA%\PassKey\native-messaging\ with correct absolute paths. +# Chrome and Firefox need separate manifests (different field names), so two files are created. +Write-Host "Generating NMH manifests..." -ForegroundColor Green + +$chromeManifest = @{ name = "com.passkey.host" description = "PassKey Native Messaging Host" path = "PassKey.BrowserHost.exe" type = "stdio" allowed_origins = @( - "chrome-extension://passkey-extension-id/" + "chrome-extension://jmddfinmjgpgmfkiblhnjccagheadpop/" ) +} | ConvertTo-Json -Depth 3 + +$firefoxManifest = @{ + name = "com.passkey.host" + description = "PassKey Native Messaging Host" + path = "PassKey.BrowserHost.exe" + type = "stdio" allowed_extensions = @( - "passkey@passkey.local" + "{3E08FACC-D43B-4B20-89E7-7888F6082E9D}" ) } | ConvertTo-Json -Depth 3 -$nmhManifest | Out-File -Encoding utf8 -FilePath (Join-Path $publishPath "com.passkey.host.json") +$chromeManifest | Out-File -Encoding utf8 -FilePath (Join-Path $publishPath "com.passkey.host.json") +$firefoxManifest | Out-File -Encoding utf8 -FilePath (Join-Path $publishPath "com.passkey.host.firefox.json") # 4. Compile Inno Setup installer if (Test-Path $InnoSetupPath) { diff --git a/src/PassKey.BrowserHost/Manifests/com.passkey.host.firefox.json b/src/PassKey.BrowserHost/Manifests/com.passkey.host.firefox.json index 232c686..250d763 100644 --- a/src/PassKey.BrowserHost/Manifests/com.passkey.host.firefox.json +++ b/src/PassKey.BrowserHost/Manifests/com.passkey.host.firefox.json @@ -4,6 +4,6 @@ "path": "A:\\Progetti\\00 PassKey\\PK4\\src\\PassKey.BrowserHost\\bin\\Debug\\net10.0\\win-x64\\PassKey.BrowserHost.exe", "type": "stdio", "allowed_extensions": [ - "passkey@passkey.local" + "{3E08FACC-D43B-4B20-89E7-7888F6082E9D}" ] } diff --git a/src/PassKey.Desktop/Services/NativeMessagingRegistrationService.cs b/src/PassKey.Desktop/Services/NativeMessagingRegistrationService.cs index c02f16a..5f3eb1c 100644 --- a/src/PassKey.Desktop/Services/NativeMessagingRegistrationService.cs +++ b/src/PassKey.Desktop/Services/NativeMessagingRegistrationService.cs @@ -23,7 +23,8 @@ public static class NativeMessagingRegistrationService internal const string ChromeExtensionId = "jmddfinmjgpgmfkiblhnjccagheadpop"; // Firefox extension ID — defined in browser_specific_settings.gecko.id in Firefox manifest. - private const string FirefoxExtensionId = "passkey@passkey.local"; + // Must match extensions/firefox/manifest.json → browser_specific_settings.gecko.id. + private const string FirefoxExtensionId = "{3E08FACC-D43B-4B20-89E7-7888F6082E9D}"; /// /// Ensures that Chrome, Edge, and Firefox Native Messaging Host manifests are written to disk