using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Net.Http; using System.Threading.Tasks; using System.Windows.Media; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using MarcusLaw.OutlookAddin.Core.Configuration; using MarcusLaw.OutlookAddin.Core.Models; using MarcusLaw.OutlookAddin.Core.Security; using MarcusLaw.OutlookAddin.Core.Services; using Serilog; namespace MarcusLaw.OutlookAddin.UI.ViewModels { public sealed partial class SettingsViewModel : ObservableObject { private static readonly Brush NeutralBrush = Brushes.Gray; private static readonly Brush ErrorBrush = Brushes.Firebrick; private static readonly Brush SuccessBrush = Brushes.SeaGreen; private readonly AppSettings _settings; private readonly SettingsManager _settingsManager; private readonly DpapiCredentialStore _credentialStore; private readonly ILogger _logger; [ObservableProperty] private string espoCrmUrl = string.Empty; [ObservableProperty] private string username = string.Empty; [ObservableProperty] private string locale = "he-IL"; [ObservableProperty] private string? statusMessage; [ObservableProperty] private Brush statusBrush = NeutralBrush; [ObservableProperty] private string? updateStatus; [ObservableProperty] private Brush updateStatusBrush = NeutralBrush; [ObservableProperty] private bool isCheckingForUpdate; public string InitialApiKey { get; } public string VersionLine { get; } public Func? ApiKeyReader { get; set; } public bool? DialogResult { get; private set; } public event EventHandler? RequestClose; public ObservableCollection Folders { get; } = new ObservableCollection(); public SettingsViewModel( AppSettings settings, SettingsManager settingsManager, DpapiCredentialStore credentialStore, ILogger logger) { _settings = settings ?? throw new ArgumentNullException(nameof(settings)); _settingsManager = settingsManager ?? throw new ArgumentNullException(nameof(settingsManager)); _credentialStore = credentialStore ?? throw new ArgumentNullException(nameof(credentialStore)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); EspoCrmUrl = settings.EspoCrmUrl; Locale = string.IsNullOrWhiteSpace(settings.Locale) ? "he-IL" : settings.Locale; var creds = _credentialStore.Load(); Username = creds?.Username ?? string.Empty; InitialApiKey = creds?.ApiKey ?? string.Empty; VersionLine = $"גרסה {ResolveDisplayVersion()}"; } /// /// Picks the most accurate version to surface in the About tab: /// the ClickOnce activation version when the add-in is installed /// over the wire (auto-updated to the real deployment version), /// else the assembly's AssemblyVersion (which is set per-source /// and may lag behind the deployed ClickOnce manifest). /// private static string ResolveDisplayVersion() { Version? v = null; try { if (System.Deployment.Application.ApplicationDeployment.IsNetworkDeployed) { v = System.Deployment.Application.ApplicationDeployment .CurrentDeployment.CurrentVersion; } } catch { // Not running under ClickOnce (dev / F5), or System.Deployment // is not available — fall through to the AssemblyVersion. } v ??= typeof(SettingsViewModel).Assembly.GetName().Version; if (v == null) return "1.0.0"; // ClickOnce stores 4 components; project policy is MAJOR.MINOR.PATCH — // hide the trailing revision when it's 0. return v.Revision == 0 ? $"{v.Major}.{v.Minor}.{v.Build}" : v.ToString(); } /// /// Populates the Folders tab. The host project owns the Outlook /// session and must build the flat tree on the STA thread before /// handing it here. /// public void LoadFolderTree(IReadOnlyList tree) { Folders.Clear(); if (tree == null) return; var watchedLookup = new Dictionary(StringComparer.Ordinal); foreach (var w in _settings.WatchedFolders) { watchedLookup[WatchedKey(w.StoreId, w.FolderPath)] = w; } foreach (var node in tree) { var row = new FolderRowViewModel(node); if (watchedLookup.TryGetValue(WatchedKey(node.StoreId, node.FolderPath), out var existing)) { row.IsWatched = true; row.Mode = existing.Mode; row.ConfidenceThreshold = existing.ConfidenceThreshold; } Folders.Add(row); } } private static string WatchedKey(string storeId, string path) => storeId + "|" + path; [RelayCommand(CanExecute = nameof(CanCheckForUpdate))] private async Task CheckForUpdateAsync() { IsCheckingForUpdate = true; UpdateStatusBrush = NeutralBrush; UpdateStatus = "בודק עדכונים…"; try { if (!System.Deployment.Application.ApplicationDeployment.IsNetworkDeployed) { UpdateStatusBrush = ErrorBrush; UpdateStatus = "התוסף לא מותקן דרך ClickOnce — אין כאן עדכון אוטומטי."; return; } var deployment = System.Deployment.Application.ApplicationDeployment.CurrentDeployment; var info = await Task.Run(() => deployment.CheckForDetailedUpdate(false)).ConfigureAwait(true); if (!info.UpdateAvailable) { UpdateStatusBrush = SuccessBrush; UpdateStatus = "אתה על הגרסה האחרונה (" + deployment.CurrentVersion + ")."; return; } UpdateStatus = $"זמינה גרסה {info.AvailableVersion} — מוריד…"; var result = await Task.Run(() => deployment.Update()).ConfigureAwait(true); UpdateStatusBrush = SuccessBrush; UpdateStatus = $"הותקנה גרסה {info.AvailableVersion}. סגור ופתח את Outlook כדי לטעון אותה."; } catch (System.Deployment.Application.DeploymentDownloadException ex) { _logger.Warning(ex, "CheckForUpdate: download failed"); UpdateStatusBrush = ErrorBrush; UpdateStatus = "הורדת העדכון נכשלה: " + ex.Message; } catch (System.Deployment.Application.InvalidDeploymentException ex) { _logger.Warning(ex, "CheckForUpdate: invalid deployment manifest"); UpdateStatusBrush = ErrorBrush; UpdateStatus = "ה-deployment manifest על השרת לא תקין: " + ex.Message; } catch (Exception ex) { _logger.Warning(ex, "CheckForUpdate failed"); UpdateStatusBrush = ErrorBrush; UpdateStatus = "שגיאה בבדיקת עדכונים: " + ex.Message; } finally { IsCheckingForUpdate = false; } } private bool CanCheckForUpdate() => !IsCheckingForUpdate; partial void OnIsCheckingForUpdateChanged(bool value) { CheckForUpdateCommand.NotifyCanExecuteChanged(); } [RelayCommand] private async Task TestConnectionAsync() { StatusBrush = NeutralBrush; StatusMessage = "בודק חיבור…"; if (!Uri.TryCreate(EspoCrmUrl, UriKind.Absolute, out var baseUrl)) { Fail("כתובת שרת לא תקינה"); return; } var apiKey = ReadApiKey(); if (string.IsNullOrWhiteSpace(apiKey)) { Fail("יש להזין מפתח API"); return; } HttpClient? http = null; try { http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; var probe = new EspoCrmClient(http, baseUrl.ToString(), Username ?? string.Empty, apiKey, _logger); var user = await probe.TestConnectionAsync().ConfigureAwait(true); Succeed($"התחברות תקינה — משתמש: {user.UserName ?? user.Id}"); } catch (EspoCrmAuthorizationException) { Fail("מפתח ה-API או שם המשתמש שגויים"); } catch (Exception ex) { _logger.Warning(ex, "TestConnection failed for {Url}", baseUrl); Fail("שגיאת חיבור: " + FormatExceptionForUser(ex, baseUrl)); } finally { http?.Dispose(); } } /// /// Walks the InnerException chain and pulls out the underlying socket /// error code so the dialog actually tells the user something useful /// (DNS miss, connection refused, TLS failure, etc.) instead of just /// "One or more errors occurred". /// private static string FormatExceptionForUser(Exception ex, Uri target) { var current = ex; while (current.InnerException != null) { current = current.InnerException; } if (current is System.Net.WebException web) { switch (web.Status) { case System.Net.WebExceptionStatus.SecureChannelFailure: return "כשל TLS — לא הצלחתי ליצור ערוץ מאובטח. ייתכן שהשרת דורש TLS 1.2/1.3 שאינו מופעל ב-.NET Framework. נסה לסגור ולפתוח Outlook מחדש; אם הבעיה חוזרת, פנה לאדמין."; case System.Net.WebExceptionStatus.TrustFailure: return "אישור ה-TLS של השרת לא נסמך. ייתכן שצריך להתקין את ה-CA של השרת ב-Trusted Root."; case System.Net.WebExceptionStatus.NameResolutionFailure: return $"השרת {target.Host} לא נמצא ב-DNS. ודא שהכתובת נכונה."; case System.Net.WebExceptionStatus.ConnectFailure: return $"החיבור ל-{target.Host}:{target.Port} נכשל. ודא שהשרת רץ ושאין חסימה ברשת."; case System.Net.WebExceptionStatus.Timeout: return "החיבור פג זמן. השרת אולי לא מגיב."; } } if (current is System.Net.Sockets.SocketException sock) { switch (sock.SocketErrorCode) { case System.Net.Sockets.SocketError.HostNotFound: return $"השרת {target.Host} לא נמצא ב-DNS. ודא שהכתובת נכונה ושיש חיבור לאינטרנט."; case System.Net.Sockets.SocketError.ConnectionRefused: return $"החיבור ל-{target.Host}:{target.Port} נדחה. ודא שהשרת רץ ומאזין."; case System.Net.Sockets.SocketError.TimedOut: return $"חיבור ל-{target.Host} פג זמן. ייתכן שהשרת לא זמין או שיש חסימה ברשת."; case System.Net.Sockets.SocketError.NetworkUnreachable: case System.Net.Sockets.SocketError.HostUnreachable: return "לא הצלחתי להגיע לרשת של השרת. בדוק את ה-VPN/חיבור הרשת."; default: return $"שגיאת רשת ({sock.SocketErrorCode}): {sock.Message}"; } } if (current is TaskCanceledException) { return "החיבור לא הגיב תוך 15 שניות. השרת אולי לא זמין או שהוא איטי."; } if (current is System.Security.Authentication.AuthenticationException) { return "אישור ה-TLS של השרת לא תקין. בדוק את הקישור (https vs http) או פנה לאדמין."; } // Fallback: outer + inner with newline so the user sees the chain. var outer = ex.GetType().Name + ": " + ex.Message; if (current != ex) { outer += " — " + current.GetType().Name + ": " + current.Message; } return outer; } [RelayCommand] private void Save() { if (!Uri.TryCreate(EspoCrmUrl, UriKind.Absolute, out _)) { Fail("כתובת שרת לא תקינה"); return; } var apiKey = ReadApiKey(); if (string.IsNullOrWhiteSpace(apiKey)) { Fail("יש להזין מפתח API"); return; } try { _settings.EspoCrmUrl = EspoCrmUrl.TrimEnd('/'); _settings.Locale = string.IsNullOrWhiteSpace(Locale) ? "he-IL" : Locale; _settings.WatchedFolders.Clear(); foreach (var row in Folders) { if (row.IsWatched) _settings.WatchedFolders.Add(row.ToWatchedFolder()); } _settingsManager.Save(_settings); _credentialStore.Save(string.IsNullOrWhiteSpace(Username) ? null : Username.Trim(), apiKey); _logger.Information("Settings saved (user={User}, url={Url}, watched={WatchedCount})", Username, _settings.EspoCrmUrl, _settings.WatchedFolders.Count); DialogResult = true; RequestClose?.Invoke(this, EventArgs.Empty); } catch (Exception ex) { _logger.Error(ex, "Settings save failed"); Fail("שמירת ההגדרות נכשלה: " + ex.Message); } } [RelayCommand] private void Cancel() { DialogResult = false; RequestClose?.Invoke(this, EventArgs.Empty); } private string ReadApiKey() => ApiKeyReader?.Invoke() ?? string.Empty; private void Fail(string message) { StatusBrush = ErrorBrush; StatusMessage = message; } private void Succeed(string message) { StatusBrush = SuccessBrush; StatusMessage = message; } } }