dbd7f3068b
Versioning policy: MAJOR.MINOR.PATCH stored as MAJOR.MINOR.PATCH.0 in the ClickOnce manifest (it requires 4 components). The trailing .0 is hidden by SettingsViewModel.ResolveDisplayVersion so the About tab shows "1.0.7" not "1.0.7.0". AutoIncrementApplicationRevision is now false; every published change must bump <ApplicationVersion> and AssemblyVersion/AssemblyFileVersion together by hand. MAJOR bumps need explicit owner approval. Previously AssemblyVersion was pinned at 1.0.0.0 while ClickOnce auto-bumped only ApplicationVersion, so the deployed DLL's FileVersion never matched the manifest. Now they're locked at 1.0.7.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
380 lines
15 KiB
C#
380 lines
15 KiB
C#
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<string>? ApiKeyReader { get; set; }
|
|
|
|
public bool? DialogResult { get; private set; }
|
|
|
|
public event EventHandler? RequestClose;
|
|
|
|
public ObservableCollection<FolderRowViewModel> Folders { get; } = new ObservableCollection<FolderRowViewModel>();
|
|
|
|
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()}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public void LoadFolderTree(IReadOnlyList<FolderTreeNode> tree)
|
|
{
|
|
Folders.Clear();
|
|
if (tree == null) return;
|
|
|
|
var watchedLookup = new Dictionary<string, WatchedFolder>(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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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".
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
}
|