feat: add Ford VP44 unlock progress dialog, K-Line fast unlock, localization, safety dialogs, and settings

Unlock progress UI:
- UnlockProgressDialog with dark-themed progress ring, phase indicator, elapsed
  time, and cancel/close buttons (non-modal, draggable borderless window)
- UnlockProgressViewModel with event-driven progress tracking via IUnlockService
- Triggers on pump selection (manual or K-Line auto-detect), not test start

UnlockService rewrite:
- Persistent CAN senders that outlive the unlock sequence (StopSenders on pump change)
- Concurrent K-Line fast unlock: awaits session Connected, sends RAM timer shortcut
  ({02 88 02 03 A8 01 00}), verifies via CAN TestUnlock before skipping wait
- Fix Type 1 verification (Value == 0 means unlocked, was inverted)

K-Line fast unlock support:
- IKwpService.TryFastUnlockAsync / KwpService implementation

Additional features:
- ILocalizationService with ES/EN resource dictionaries and runtime switching
- Safety dialogs: VoltageWarning, OilPumpConfirm, RpmSafetyWarning
- SettingsDialog for app configuration
- BenchService enhancements, ConfigurationService improvements, PDF report updates
- All UI strings localized via DynamicResource

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 13:22:48 +02:00
parent c617854c09
commit 37d099cdbd
55 changed files with 3207 additions and 379 deletions

View File

@@ -2,6 +2,8 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Xml.Linq;
using HC_APTBS.Models;
using Peak.Can.Basic;
@@ -460,6 +462,18 @@ namespace HC_APTBS.Services.Impl
_log.Error(LogId, $"LoadSettings failed: {ex.Message}");
}
// Seed default admin account if no users are configured.
if (string.IsNullOrEmpty(_settings.Users))
{
var (salt, hash) = HashPassword("admin");
_settings.Users = $"admin:{salt}:{hash}";
_log.Info(LogId, "No users configured — created default 'admin' account.");
SaveSettings();
}
// Migrate plaintext user:password entries to hashed format.
MigrateUsersIfNeeded();
LoadSensors();
LoadClients();
LoadAlarms();
@@ -742,7 +756,83 @@ namespace HC_APTBS.Services.Impl
catch { }
}
// ── Users ─────────────────────────────────────────────────────────────────
// ── Users (PBKDF2-HMAC-SHA256 hashed credentials) ─────────────────────────
private const int SaltBytes = 16;
private const int HashBytes = 32;
private const int Pbkdf2Iterations = 600_000;
/// <summary>Generates a random salt and computes the PBKDF2-HMAC-SHA256 hash for <paramref name="password"/>.</summary>
private static (string salt, string hash) HashPassword(string password)
{
byte[] salt = RandomNumberGenerator.GetBytes(SaltBytes);
byte[] hash = Rfc2898DeriveBytes.Pbkdf2(
password, salt, Pbkdf2Iterations, HashAlgorithmName.SHA256, HashBytes);
return (Convert.ToBase64String(salt), Convert.ToBase64String(hash));
}
/// <summary>Verifies <paramref name="password"/> against the given Base64 <paramref name="salt"/> and <paramref name="expectedHash"/>.</summary>
private static bool VerifyPassword(string password, string salt, string expectedHash)
{
byte[] saltBytes = Convert.FromBase64String(salt);
byte[] computed = Rfc2898DeriveBytes.Pbkdf2(
password, saltBytes, Pbkdf2Iterations, HashAlgorithmName.SHA256, HashBytes);
return CryptographicOperations.FixedTimeEquals(computed, Convert.FromBase64String(expectedHash));
}
/// <summary>
/// Detects whether <see cref="AppSettings.Users"/> contains legacy plaintext
/// <c>user:password</c> entries and migrates them to <c>user:salt:hash</c>.
/// </summary>
private void MigrateUsersIfNeeded()
{
if (string.IsNullOrEmpty(Settings.Users))
return;
string[] entries = Settings.Users.Split(',');
bool hasLegacy = false;
foreach (string entry in entries)
{
// New format always has exactly 3 colon-separated parts (user:salt:hash).
// Legacy format has exactly 2 parts (user:password).
// Base64 salt/hash never contain commas but may contain '=' padding —
// they will NOT contain additional colons, so Split(':') count is reliable.
string[] parts = entry.Split(':');
if (parts.Length == 2)
{
hasLegacy = true;
break;
}
}
if (!hasLegacy) return;
var migrated = new List<string>(entries.Length);
foreach (string entry in entries)
{
string[] parts = entry.Split(':');
if (parts.Length == 2 && parts[0].Length > 0)
{
// Legacy entry — hash the plaintext password.
var (salt, hash) = HashPassword(parts[1]);
migrated.Add($"{parts[0]}:{salt}:{hash}");
}
else if (parts.Length == 3 && parts[0].Length > 0)
{
// Already migrated entry — keep as-is.
migrated.Add(entry);
}
else
{
_log.Warning(LogId, $"Skipped malformed user entry during migration: '{entry}'");
}
}
Settings.Users = string.Join(",", migrated);
SaveSettings();
_log.Info(LogId, $"Migrated {entries.Length} user credential(s) from plaintext to PBKDF2 hashed format.");
}
/// <inheritdoc/>
public bool ValidateUser(string username, string password)
@@ -750,25 +840,26 @@ namespace HC_APTBS.Services.Impl
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
return false;
string check = username + ":" + password;
foreach (string entry in Settings.Users.Split(','))
{
if (entry == check) return true;
string[] parts = entry.Split(':');
if (parts.Length == 3 && parts[0] == username)
return VerifyPassword(password, parts[1], parts[2]);
}
return false;
}
/// <inheritdoc/>
public IReadOnlyDictionary<string, string> GetUsers()
public IReadOnlyList<string> GetUsers()
{
var dict = new Dictionary<string, string>();
foreach (string kv in Settings.Users.Split(','))
var names = new List<string>();
foreach (string entry in Settings.Users.Split(','))
{
string[] parts = kv.Split(':');
if (parts.Length == 2 && parts[0].Length > 0)
dict[parts[0]] = parts[1];
string[] parts = entry.Split(':');
if (parts.Length == 3 && parts[0].Length > 0)
names.Add(parts[0]);
}
return dict;
return names;
}
/// <inheritdoc/>
@@ -776,7 +867,10 @@ namespace HC_APTBS.Services.Impl
{
var entries = new List<string>(users.Count);
foreach (var kv in users)
entries.Add(kv.Key + ":" + kv.Value);
{
var (salt, hash) = HashPassword(kv.Value);
entries.Add($"{kv.Key}:{salt}:{hash}");
}
Settings.Users = string.Join(",", entries);
SaveSettings();