diff --git a/Directory.Packages.props b/Directory.Packages.props index 9b029b6..fac4d95 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -63,6 +63,10 @@ NuGetAudit + TreatWarningsAsErrors the advisory also fails restore, so staying on 2025.1.0 is not an option anyway. --> + + M19.07,4.93 A10,10 0 0 0 6.99,3.34 M4,6 h0.01 M2.29,9.62 A10,10 0 1 0 21.31,8.35 M16.24,7.76 A6,6 0 1 0 8.23,16.67 M12,18 h0.01 M17.99,11.66 A6,6 0 0 1 15.77,16.67 M10,12 A2,2 0 1 0 14,12 A2,2 0 1 0 10,12 Z M13.41,10.59 L19.07,4.93 + + M10.268,21 a2,2 0 0 0 3.464,0 M3.262,15.326 A1,1 0 0 0 4,17 h16 a1,1 0 0 0 0.74,-1.673 C19.41,13.956 18,12.499 18,8 A6,6 0 0 0 6,8 c0,4.499 -1.411,5.956 -2.738,7.326 M10,5 H3 M12,19 H3 M14,3 V7 M16,17 V21 M21,12 H12 M21,19 H16 M21,5 H14 M8,10 V14 M8,12 H3 diff --git a/src/OpenIPC.Viewer.App/ViewModels/NotificationSettingsViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/NotificationSettingsViewModel.cs new file mode 100644 index 0000000..6156eed --- /dev/null +++ b/src/OpenIPC.Viewer.App/ViewModels/NotificationSettingsViewModel.cs @@ -0,0 +1,575 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using OpenIPC.Viewer.App.Services; +using OpenIPC.Viewer.Core.Events; +using OpenIPC.Viewer.Core.Notifications; + +namespace OpenIPC.Viewer.App.ViewModels; + +// Settings → Notifications: what fires (types + thresholds + cooldown + quiet +// hours), how it is shown (app toast with own sound, or native Windows toast), +// and the external actions (TCP/UDP signal, webhook, e-mail, program). +// +// Field edits are saved debounced (typing in a text box must not rewrite the +// settings file per keystroke) through UserSettingsService's functional update, +// so they never clobber fields owned by the main settings page. The SMTP +// password is never part of usersettings.json — "Save login" writes it to the +// credential store (Windows Credential Manager on Windows). +public sealed partial class NotificationSettingsViewModel : ViewModelBase +{ + private static readonly TimeSpan SaveDelay = TimeSpan.FromMilliseconds(400); + + private static readonly HashSet Persisted = new(StringComparer.Ordinal) + { + nameof(NotificationsEnabled), nameof(NotifyOnMotion), nameof(NotifyOnDetection), + nameof(NotificationCooldownSeconds), nameof(QuietHoursEnabled), nameof(QuietHoursStartHour), + nameof(QuietHoursEndHour), nameof(MinConfidencePercent), nameof(MinDuration), nameof(MinObjects), + nameof(UseSystemStyle), nameof(SoundMode), nameof(Volume), + nameof(ActionsEnabled), nameof(ActionsIgnoreQuietHours), nameof(ActionsCooldown), + nameof(SignalEnabled), nameof(SignalProtocol), nameof(SignalHost), nameof(SignalPort), nameof(SignalPayload), + nameof(HttpEnabled), nameof(HttpMethod), nameof(HttpUrl), nameof(HttpBody), + nameof(EmailEnabled), nameof(EmailTransport), nameof(EmailTo), nameof(EmailSubject), nameof(EmailBody), + nameof(SmtpHost), nameof(SmtpPort), nameof(SmtpSecurity), nameof(SmtpFrom), + nameof(ProgramEnabled), nameof(ProgramPath), nameof(ProgramArguments), + }; + + private readonly UserSettingsService _settings; + private readonly INotificationService _notifications; + private readonly NotificationSoundService _sound; + private readonly ISystemNotificationSink? _system; + private readonly IEventActionDispatcher _actions; + private readonly IMailCredentialStore _credentials; + private readonly IDialogService _dialogs; + private readonly ILogger _log; + + private bool _suppressSave; + private bool _credentialsDirty; + private CancellationTokenSource? _pendingSave; + private string _customSoundFile = ""; + + public NotificationSettingsViewModel( + UserSettingsService settings, + INotificationService notifications, + NotificationSoundService sound, + IEventActionDispatcher actions, + IMailCredentialStore credentials, + IDialogService dialogs, + ILogger log, + ISystemNotificationSink? system = null) + { + _settings = settings; + _notifications = notifications; + _sound = sound; + _actions = actions; + _credentials = credentials; + _dialogs = dialogs; + _log = log; + _system = system; + + var l = Localizer.Instance; + DurationOptions = new[] { 0, 1, 2, 3, 5, 10, 20, 30, 60 } + .Select(s => new IntOption(s == 0 + ? l["Settings.Notify.Duration.Any"] + : string.Format(CultureInfo.CurrentCulture, l["Settings.Notify.Seconds"], s), s)) + .ToList(); + ObjectOptions = new[] { 1, 2, 3, 4, 5, 10 } + .Select(n => new IntOption(n == 1 + ? l["Settings.Notify.Objects.Any"] + : string.Format(CultureInfo.CurrentCulture, l["Settings.Notify.Objects.AtLeast"], n), n)) + .ToList(); + ActionCooldownOptions = new[] { 0, 10, 30, 60, 120, 300, 600 } + .Select(s => new IntOption(s == 0 + ? l["Settings.Notify.Cooldown.None"] + : string.Format(CultureInfo.CurrentCulture, l["Settings.Notify.Seconds"], s), s)) + .ToList(); + SoundModeOptions = new[] + { + new StringOption(l["Settings.Notify.Sound.Chime"], NotificationSoundService.SoundChime), + new StringOption(l["Settings.Notify.Sound.Custom"], NotificationSoundService.SoundCustom), + new StringOption(l["Settings.Notify.Sound.Off"], NotificationSoundService.SoundOff), + }; + SignalProtocolOptions = new[] + { + new StringOption("TCP", EventActionSettings.ProtocolTcp), + new StringOption("UDP", EventActionSettings.ProtocolUdp), + }; + var transports = new List + { + new(l["Settings.Actions.Email.Transport.Smtp"], EventActionSettings.TransportSmtp), + }; + if (IsWindows) + { + transports.Add(new(l["Settings.Actions.Email.Transport.Mapi"], EventActionSettings.TransportMapi)); + transports.Add(new(l["Settings.Actions.Email.Transport.Outlook"], EventActionSettings.TransportOutlook)); + } + EmailTransportOptions = transports; + SmtpSecurityOptions = new[] + { + new StringOption(l["Settings.Actions.Smtp.Security.Auto"], "auto"), + new StringOption("SSL/TLS", "ssl"), + new StringOption("STARTTLS", "starttls"), + new StringOption(l["Settings.Actions.Smtp.Security.None"], "none"), + }; + + Load(); + } + + // --- platform gates ------------------------------------------------------ + + public bool IsWindows { get; } = OperatingSystem.IsWindows(); + public bool IsDesktop { get; } = !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS(); + public bool IsSystemStyleSupported => _system is { IsSupported: true }; + public bool IsSoundAvailable => _sound.IsAvailable; + + // --- what fires ------------------------------------------------------------ + + [ObservableProperty] private bool _notificationsEnabled = true; + [ObservableProperty] private bool _notifyOnMotion = true; + [ObservableProperty] private bool _notifyOnDetection = true; + [ObservableProperty] private int _notificationCooldownSeconds = 30; + [ObservableProperty] private bool _quietHoursEnabled; + [ObservableProperty] private int _quietHoursStartHour = 22; + [ObservableProperty] private int _quietHoursEndHour = 7; + public int[] HourOptions { get; } = Enumerable.Range(0, 24).ToArray(); + public int[] CooldownOptions { get; } = { 0, 10, 30, 60, 120, 300 }; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(MinConfidenceText))] + private double _minConfidencePercent; + [ObservableProperty] private IntOption? _minDuration; + [ObservableProperty] private IntOption? _minObjects; + public IReadOnlyList DurationOptions { get; } + public IReadOnlyList ObjectOptions { get; } + + public string MinConfidenceText => (int)MinConfidencePercent <= 0 + ? Localizer.Instance["Settings.Notify.Confidence.Off"] + : $"≥ {(int)MinConfidencePercent} %"; + + // --- how it looks / sounds ----------------------------------------------- + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(UseAppStyle))] + [NotifyPropertyChangedFor(nameof(ShowSoundSettings))] + private bool _useSystemStyle; + + // Second radio button of the pair. + public bool UseAppStyle + { + get => !UseSystemStyle; + set { if (value) UseSystemStyle = false; } + } + + public bool ShowSoundSettings => IsSoundAvailable && !UseSystemStyle; + + [ObservableProperty] private string? _systemStatus; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsCustomSound))] + private StringOption? _soundMode; + public IReadOnlyList SoundModeOptions { get; } + public bool IsCustomSound => SoundMode?.Value == NotificationSoundService.SoundCustom; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(VolumeText))] + private double _volume = 70; + public string VolumeText => $"{(int)Volume} %"; + + [ObservableProperty] private string _customSoundName = ""; + [ObservableProperty] private string? _soundStatus; + + // --- external actions ---------------------------------------------------- + + [ObservableProperty] private bool _actionsEnabled; + [ObservableProperty] private bool _actionsIgnoreQuietHours = true; + [ObservableProperty] private IntOption? _actionsCooldown; + public IReadOnlyList ActionCooldownOptions { get; } + + public string PlaceholdersHint { get; } = + string.Join(" ", EventActionTemplate.Placeholders.Select(p => "{" + p + "}")); + + [ObservableProperty] private bool _signalEnabled; + [ObservableProperty] private StringOption? _signalProtocol; + [ObservableProperty] private string _signalHost = ""; + [ObservableProperty] private int _signalPort = 9000; + [ObservableProperty] private string _signalPayload = ""; + [ObservableProperty] private string? _signalStatus; + public IReadOnlyList SignalProtocolOptions { get; } + + [ObservableProperty] private bool _httpEnabled; + [ObservableProperty] private string _httpMethod = "POST"; + [ObservableProperty] private string _httpUrl = ""; + [ObservableProperty] private string _httpBody = ""; + [ObservableProperty] private string? _httpStatus; + public string[] HttpMethodOptions { get; } = { "POST", "GET", "PUT" }; + + [ObservableProperty] private bool _emailEnabled; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsSmtp))] + [NotifyPropertyChangedFor(nameof(IsMapi))] + [NotifyPropertyChangedFor(nameof(IsOutlook))] + private StringOption? _emailTransport; + public IReadOnlyList EmailTransportOptions { get; } + public bool IsSmtp => EmailTransport?.Value is null or EventActionSettings.TransportSmtp; + public bool IsMapi => EmailTransport?.Value == EventActionSettings.TransportMapi; + public bool IsOutlook => EmailTransport?.Value == EventActionSettings.TransportOutlook; + + [ObservableProperty] private string _emailTo = ""; + [ObservableProperty] private string _emailSubject = ""; + [ObservableProperty] private string _emailBody = ""; + [ObservableProperty] private string _smtpHost = ""; + [ObservableProperty] private int _smtpPort = 587; + [ObservableProperty] private StringOption? _smtpSecurity; + public IReadOnlyList SmtpSecurityOptions { get; } + [ObservableProperty] private string _smtpFrom = ""; + [ObservableProperty] private string _smtpUser = ""; + [ObservableProperty] private string _smtpPassword = ""; + [ObservableProperty] private string? _emailStatus; + + public string CredentialStorageHint => Localizer.Instance[IsWindows + ? "Settings.Actions.Smtp.StoredIn.Windows" + : "Settings.Actions.Smtp.StoredIn.Secure"]; + + [ObservableProperty] private bool _programEnabled; + [ObservableProperty] private string _programPath = ""; + [ObservableProperty] private string _programArguments = ""; + [ObservableProperty] private string? _programStatus; + + partial void OnSmtpUserChanged(string value) { if (!_suppressSave) _credentialsDirty = true; } + partial void OnSmtpPasswordChanged(string value) { if (!_suppressSave) _credentialsDirty = true; } + + // --- load / save --------------------------------------------------------- + + private void Load() + { + _suppressSave = true; + try + { + var s = _settings.Current; + NotificationsEnabled = s.NotificationsEnabled; + NotifyOnMotion = s.NotifyOnMotion; + NotifyOnDetection = s.NotifyOnDetection; + NotificationCooldownSeconds = s.NotificationCooldownSeconds; + QuietHoursEnabled = s.QuietHoursEnabled; + QuietHoursStartHour = s.QuietHoursStartHour; + QuietHoursEndHour = s.QuietHoursEndHour; + MinConfidencePercent = Math.Clamp(s.NotifyMinConfidencePercent, 0, 95); + MinDuration = Pick(DurationOptions, s.NotifyMinDurationSeconds); + MinObjects = Pick(ObjectOptions, s.NotifyMinObjects); + + UseSystemStyle = IsSystemStyleSupported && + string.Equals(s.NotificationStyle, DesktopNotificationService.StyleWindows, StringComparison.OrdinalIgnoreCase); + SoundMode = SoundModeOptions.FirstOrDefault(o => string.Equals(o.Value, s.NotificationSound, StringComparison.OrdinalIgnoreCase)) + ?? SoundModeOptions[0]; + Volume = Math.Clamp(s.NotificationVolume, 0, 100); + _customSoundFile = s.NotificationSoundFile ?? ""; + CustomSoundName = s.NotificationSoundName ?? ""; + + var a = s.EventActions ?? EventActionSettings.Default; + ActionsEnabled = a.Enabled; + ActionsIgnoreQuietHours = a.IgnoreQuietHours; + ActionsCooldown = Pick(ActionCooldownOptions, a.CooldownSeconds); + SignalEnabled = a.SignalEnabled; + SignalProtocol = SignalProtocolOptions.FirstOrDefault(o => string.Equals(o.Value, a.SignalProtocol, StringComparison.OrdinalIgnoreCase)) + ?? SignalProtocolOptions[0]; + SignalHost = a.SignalHost; + SignalPort = a.SignalPort > 0 ? a.SignalPort : 9000; + SignalPayload = a.SignalPayload; + HttpEnabled = a.HttpEnabled; + HttpMethod = HttpMethodOptions.FirstOrDefault(m => string.Equals(m, a.HttpMethod, StringComparison.OrdinalIgnoreCase)) ?? "POST"; + HttpUrl = a.HttpUrl; + HttpBody = a.HttpBody; + EmailEnabled = a.EmailEnabled; + EmailTransport = EmailTransportOptions.FirstOrDefault(o => string.Equals(o.Value, a.EmailTransport, StringComparison.OrdinalIgnoreCase)) + ?? EmailTransportOptions[0]; + EmailTo = a.EmailTo; + EmailSubject = a.EmailSubject; + EmailBody = a.EmailBody; + SmtpHost = a.SmtpHost; + SmtpPort = a.SmtpPort > 0 ? a.SmtpPort : 587; + SmtpSecurity = SmtpSecurityOptions.FirstOrDefault(o => string.Equals(o.Value, a.SmtpSecurity, StringComparison.OrdinalIgnoreCase)) + ?? SmtpSecurityOptions[0]; + SmtpFrom = a.SmtpFrom; + ProgramEnabled = a.ProgramEnabled; + ProgramPath = a.ProgramPath; + ProgramArguments = a.ProgramArguments; + } + finally { _suppressSave = false; } + } + + // Called when the Settings page is shown: the SMTP login (async store) and + // the Windows notification state (a WinRT call). + public async Task LoadAsync() + { + try + { + var cred = await _credentials.GetAsync(CancellationToken.None).ConfigureAwait(true); + _suppressSave = true; + try + { + SmtpUser = cred?.UserName ?? ""; + SmtpPassword = cred?.Password ?? ""; + } + finally { _suppressSave = false; } + _credentialsDirty = false; + } + catch (Exception ex) + { + _log.LogWarning(ex, "Could not read the SMTP login from {Store}", _credentials.StorageName); + } + await RefreshSystemStatusAsync().ConfigureAwait(true); + } + + private static IntOption Pick(IReadOnlyList options, int value) => + options.FirstOrDefault(o => o.Value == value) + ?? options.LastOrDefault(o => o.Value <= value) + ?? options[0]; + + protected override void OnPropertyChanged(PropertyChangedEventArgs e) + { + base.OnPropertyChanged(e); + if (_suppressSave || e.PropertyName is null || !Persisted.Contains(e.PropertyName)) return; + if (e.PropertyName == nameof(UseSystemStyle)) _ = RefreshSystemStatusAsync(); + ScheduleSave(); + } + + private void ScheduleSave() + { + _pendingSave?.Cancel(); + var cts = _pendingSave = new CancellationTokenSource(); + _ = SaveLaterAsync(cts.Token); + } + + private async Task SaveLaterAsync(CancellationToken ct) + { + try { await Task.Delay(SaveDelay, ct).ConfigureAwait(true); } + catch (OperationCanceledException) { return; } + await SaveNowAsync().ConfigureAwait(true); + } + + // Writes a still-pending debounced edit now (page left mid-typing). + public Task FlushAsync() => _pendingSave is null ? Task.CompletedTask : SaveNowAsync(); + + // Writes immediately (Test buttons need the current values on disk). + public Task SaveNowAsync() + { + _pendingSave?.Cancel(); + _pendingSave = null; + + // Snapshot on the UI thread; the mutation may run on the save thread. + var enabled = NotificationsEnabled; + var onMotion = NotifyOnMotion; + var onDetection = NotifyOnDetection; + var cooldown = NotificationCooldownSeconds; + var quiet = QuietHoursEnabled; + var quietStart = QuietHoursStartHour; + var quietEnd = QuietHoursEndHour; + var minConfidence = (int)Math.Round(MinConfidencePercent); + var minDuration = MinDuration?.Value ?? 0; + var minObjects = MinObjects?.Value ?? 1; + var style = UseSystemStyle ? DesktopNotificationService.StyleWindows : DesktopNotificationService.StyleApp; + var sound = SoundMode?.Value ?? NotificationSoundService.SoundChime; + var volume = (int)Math.Round(Volume); + var soundFile = _customSoundFile; + var soundName = CustomSoundName; + var actions = BuildActions(); + + return _settings.UpdateAsync(s => s with + { + NotificationsEnabled = enabled, + NotifyOnMotion = onMotion, + NotifyOnDetection = onDetection, + NotificationCooldownSeconds = cooldown, + QuietHoursEnabled = quiet, + QuietHoursStartHour = quietStart, + QuietHoursEndHour = quietEnd, + NotifyMinConfidencePercent = minConfidence, + NotifyMinDurationSeconds = minDuration, + NotifyMinObjects = minObjects, + NotificationStyle = style, + NotificationSound = sound, + NotificationVolume = volume, + NotificationSoundFile = soundFile, + NotificationSoundName = soundName, + EventActions = actions, + }); + } + + private EventActionSettings BuildActions() => new( + Enabled: ActionsEnabled, + IgnoreQuietHours: ActionsIgnoreQuietHours, + CooldownSeconds: ActionsCooldown?.Value ?? 60, + SignalEnabled: SignalEnabled, + SignalProtocol: SignalProtocol?.Value ?? EventActionSettings.ProtocolTcp, + SignalHost: SignalHost.Trim(), + SignalPort: SignalPort, + SignalPayload: SignalPayload, + HttpEnabled: HttpEnabled, + HttpMethod: HttpMethod, + HttpUrl: HttpUrl.Trim(), + HttpBody: HttpBody, + EmailEnabled: EmailEnabled, + EmailTransport: EmailTransport?.Value ?? EventActionSettings.TransportSmtp, + EmailTo: EmailTo.Trim(), + EmailSubject: EmailSubject, + EmailBody: EmailBody, + SmtpHost: SmtpHost.Trim(), + SmtpPort: SmtpPort, + SmtpSecurity: SmtpSecurity?.Value ?? "auto", + SmtpFrom: SmtpFrom.Trim(), + ProgramEnabled: ProgramEnabled, + ProgramPath: ProgramPath.Trim(), + ProgramArguments: ProgramArguments); + + // --- commands: look & sound ---------------------------------------------- + + [RelayCommand] + private async Task TestNotificationAsync() + { + await SaveNowAsync().ConfigureAwait(true); + // Straight at the sink — bypasses thresholds/cooldown on purpose, it's a preview. + _notifications.Show(new NotificationRequest( + Localizer.Instance["Settings.Notifications.TestTitle"], + Localizer.Instance["Settings.Notifications.TestBody"], + EventKind.Motion)); + if (UseSystemStyle) await RefreshSystemStatusAsync().ConfigureAwait(true); + } + + [RelayCommand] + private void PreviewSound() => + _sound.Play(SoundMode?.Value ?? NotificationSoundService.SoundChime, _customSoundFile, (int)Math.Round(Volume)); + + [RelayCommand] + private async Task PickSoundAsync() + { + var path = await _dialogs.PickAnyFileAsync(Localizer.Instance["Settings.Notify.Sound.PickTitle"]).ConfigureAwait(true); + if (string.IsNullOrEmpty(path)) return; + try + { + SoundStatus = Localizer.Instance["Settings.Notify.Sound.Importing"]; + _customSoundFile = await _sound.ImportAsync(path, CancellationToken.None).ConfigureAwait(true); + CustomSoundName = Path.GetFileName(path); + SoundMode = SoundModeOptions.First(o => o.Value == NotificationSoundService.SoundCustom); + SoundStatus = null; + await SaveNowAsync().ConfigureAwait(true); + PreviewSound(); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Importing notification sound {Path} failed", path); + SoundStatus = string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Settings.Notify.Sound.ImportFailed"], ex.Message); + } + } + + [RelayCommand] + private void OpenSystemSettings() => _system?.OpenSettings(); + + // Only in the Windows style: querying the state registers our AppUserModelID, + // which users of the built-in style never need. + private async Task RefreshSystemStatusAsync() + { + if (_system is not { IsSupported: true } || !UseSystemStyle) return; + var state = await Task.Run(() => _system.State).ConfigureAwait(true); + SystemStatus = Localizer.Instance[state switch + { + SystemNotificationState.Enabled => "Settings.Notify.System.Enabled", + SystemNotificationState.DisabledForApp => "Settings.Notify.System.DisabledForApp", + SystemNotificationState.DisabledForUser => "Settings.Notify.System.DisabledForUser", + SystemNotificationState.DisabledByPolicy => "Settings.Notify.System.DisabledByPolicy", + _ => "Settings.Notify.System.Unknown", + }]; + } + + // --- commands: external actions ------------------------------------------ + + [RelayCommand] + private Task TestSignalAsync() => TestChannelAsync(EventActionChannel.Signal, s => SignalStatus = s); + + [RelayCommand] + private Task TestHttpAsync() => TestChannelAsync(EventActionChannel.Http, s => HttpStatus = s); + + [RelayCommand] + private async Task TestEmailAsync() + { + if (IsSmtp && _credentialsDirty) await SaveCredentialsAsync().ConfigureAwait(true); + await TestChannelAsync(EventActionChannel.Email, s => EmailStatus = s).ConfigureAwait(true); + } + + [RelayCommand] + private Task TestProgramAsync() => TestChannelAsync(EventActionChannel.Program, s => ProgramStatus = s); + + private async Task TestChannelAsync(EventActionChannel channel, Action report) + { + report(Localizer.Instance["Settings.Actions.Testing"]); + try + { + await SaveNowAsync().ConfigureAwait(true); + var ctx = EventActionContext.Sample(DateTime.UtcNow) with + { + Title = Localizer.Instance["Settings.Notifications.TestTitle"], + }; + await _actions.TestAsync(channel, ctx, BuildActions(), CancellationToken.None).ConfigureAwait(true); + report(Localizer.Instance["Settings.Actions.TestOk"]); + } + catch (Exception ex) + { + report(string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Settings.Actions.TestFailed"], ex.Message)); + } + } + + [RelayCommand] + private async Task SaveCredentialsAsync() + { + try + { + if (string.IsNullOrWhiteSpace(SmtpUser) && string.IsNullOrEmpty(SmtpPassword)) + await _credentials.ClearAsync(CancellationToken.None).ConfigureAwait(true); + else + await _credentials.SetAsync(new MailCredential(SmtpUser.Trim(), SmtpPassword), CancellationToken.None).ConfigureAwait(true); + _credentialsDirty = false; + EmailStatus = CredentialStorageHint; + } + catch (Exception ex) + { + EmailStatus = string.Format(CultureInfo.CurrentCulture, Localizer.Instance["Settings.Actions.TestFailed"], ex.Message); + } + } + + // Control Panel → Credential Manager, where the stored SMTP login can be + // reviewed, edited or removed with the standard Windows tool. + [RelayCommand] + private void OpenCredentialManager() + { + if (!OperatingSystem.IsWindows()) return; + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("control.exe", "/name Microsoft.CredentialManager") + { + UseShellExecute = true, + }); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Could not open Credential Manager"); + } + } + + [RelayCommand] + private async Task PickProgramAsync() + { + var path = await _dialogs.PickAnyFileAsync(Localizer.Instance["Settings.Actions.Program.PickTitle"]).ConfigureAwait(true); + if (!string.IsNullOrEmpty(path)) ProgramPath = path; + } +} + +// Combo items with a localized label and the persisted value. +public sealed record IntOption(string Display, int Value); +public sealed record StringOption(string Display, string Value); diff --git a/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs index d5b64c0..e0701e5 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs @@ -25,7 +25,6 @@ public sealed partial class SettingsPageViewModel : ViewModelBase, IBackNavigabl private readonly ISshHostKeyStore _hostKeys; private readonly OpenIPC.Viewer.Core.Persistence.IConfigBackupService _backup; private readonly ConfigSyncService _configSync; - private readonly OpenIPC.Viewer.Core.Notifications.INotificationService _notifications; private readonly OpenIPC.Viewer.Core.Persistence.ILayoutRepository _layouts; private readonly ILogger _logger; private bool _suppressSave; @@ -110,16 +109,9 @@ private static TerminalEmulator CreateTerminalPreview() return preview; } - // Notifications (Phase 19.3). - [ObservableProperty] private bool _notificationsEnabled = true; - [ObservableProperty] private bool _notifyOnMotion = true; - [ObservableProperty] private bool _notifyOnDetection = true; - [ObservableProperty] private int _notificationCooldownSeconds = 30; - [ObservableProperty] private bool _quietHoursEnabled; - [ObservableProperty] private int _quietHoursStartHour = 22; - [ObservableProperty] private int _quietHoursEndHour = 7; - public int[] HourOptions { get; } = System.Linq.Enumerable.Range(0, 24).ToArray(); - public int[] CooldownOptions { get; } = new[] { 0, 10, 30, 60, 120, 300 }; + // Notifications (Phase 19.3) — its own section with its own view model + // (types, thresholds, style, sound, external actions). + public NotificationSettingsViewModel Notifications { get; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(EffectiveRecordingsDirectory))] @@ -127,10 +119,10 @@ private static TerminalEmulator CreateTerminalPreview() private string _recordingsDirOverride = ""; // Master-detail state. -1 = no section selected (list view on narrow); - // 0..5 maps to Appearance/Video/Recording/Discovery/Advanced/About. On - // wide viewports a section is always selected (defaults to 0). Code-behind - // toggles IsWide based on viewport width and resizes the Grid columns to - // hide one pane on narrow. + // 0..7 maps to Appearance/Video/Recording/Discovery/SSH/Advanced/ + // Notifications/About. On wide viewports a section is always selected + // (defaults to 0). Code-behind toggles IsWide based on viewport width and + // resizes the Grid columns to hide one pane on narrow. [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsAppearance))] [NotifyPropertyChangedFor(nameof(IsVideo))] @@ -138,6 +130,7 @@ private static TerminalEmulator CreateTerminalPreview() [NotifyPropertyChangedFor(nameof(IsDiscovery))] [NotifyPropertyChangedFor(nameof(IsSsh))] [NotifyPropertyChangedFor(nameof(IsAdvanced))] + [NotifyPropertyChangedFor(nameof(IsNotifications))] [NotifyPropertyChangedFor(nameof(IsAbout))] [NotifyPropertyChangedFor(nameof(ShowList))] [NotifyPropertyChangedFor(nameof(ShowDetail))] @@ -156,7 +149,8 @@ private static TerminalEmulator CreateTerminalPreview() public bool IsDiscovery => SelectedSectionIndex == 3; public bool IsSsh => SelectedSectionIndex == 4; public bool IsAdvanced => SelectedSectionIndex == 5; - public bool IsAbout => SelectedSectionIndex == 6; + public bool IsNotifications => SelectedSectionIndex == 6; + public bool IsAbout => SelectedSectionIndex == 7; public bool ShowList => IsWide || SelectedSectionIndex < 0; public bool ShowDetail => IsWide || SelectedSectionIndex >= 0; @@ -185,7 +179,7 @@ public bool TryGoBack() // Deep-link target for the tray "About" item — keeps the section index in // one place (it shifts between editions with different section sets). - public void SelectAboutSection() => SelectedSectionIndex = 6; + public void SelectAboutSection() => SelectedSectionIndex = 7; public bool IsRecordingsDirOverridden => !string.IsNullOrWhiteSpace(RecordingsDirOverride); @@ -227,7 +221,7 @@ public SettingsPageViewModel( ISshHostKeyStore hostKeys, OpenIPC.Viewer.Core.Persistence.IConfigBackupService backup, ConfigSyncService configSync, - OpenIPC.Viewer.Core.Notifications.INotificationService notifications, + NotificationSettingsViewModel notifications, OpenIPC.Viewer.Core.Persistence.ILayoutRepository layouts, ILogger logger) { @@ -237,7 +231,7 @@ public SettingsPageViewModel( _hostKeys = hostKeys; _backup = backup; _configSync = configSync; - _notifications = notifications; + Notifications = notifications; _layouts = layouts; _logger = logger; @@ -318,13 +312,6 @@ private void Load() SshTerminalCursorBlink = s.SshTerminalCursorBlink; SshTerminalScrollback = s.SshTerminalScrollback; MajesticConfigPath = s.MajesticConfigPath; - NotificationsEnabled = s.NotificationsEnabled; - NotifyOnMotion = s.NotifyOnMotion; - NotifyOnDetection = s.NotifyOnDetection; - NotificationCooldownSeconds = s.NotificationCooldownSeconds; - QuietHoursEnabled = s.QuietHoursEnabled; - QuietHoursStartHour = s.QuietHoursStartHour; - QuietHoursEndHour = s.QuietHoursEndHour; ConfigSyncEnabled = s.ConfigSyncEnabled; ConfigSyncPath = s.ConfigSyncPath; ConfigSyncIncludeCredentials = s.ConfigSyncIncludeCredentials; @@ -357,13 +344,6 @@ private void Load() partial void OnSshTerminalCursorBlinkChanged(bool value) => Persist(); partial void OnSshTerminalScrollbackChanged(int value) => Persist(); partial void OnMajesticConfigPathChanged(string value) => Persist(); - partial void OnNotificationsEnabledChanged(bool value) => Persist(); - partial void OnNotifyOnMotionChanged(bool value) => Persist(); - partial void OnNotifyOnDetectionChanged(bool value) => Persist(); - partial void OnNotificationCooldownSecondsChanged(int value) => Persist(); - partial void OnQuietHoursEnabledChanged(bool value) => Persist(); - partial void OnQuietHoursStartHourChanged(int value) => Persist(); - partial void OnQuietHoursEndHourChanged(int value) => Persist(); private void Persist() { @@ -398,13 +378,6 @@ private void Persist() SshTerminalScrollback = Math.Clamp(SshTerminalScrollback, TerminalEmulator.MinScrollbackLimit, TerminalEmulator.MaxScrollbackLimit), MajesticConfigPath = MajesticConfigPath, - NotificationsEnabled = NotificationsEnabled, - NotifyOnMotion = NotifyOnMotion, - NotifyOnDetection = NotifyOnDetection, - NotificationCooldownSeconds = NotificationCooldownSeconds, - QuietHoursEnabled = QuietHoursEnabled, - QuietHoursStartHour = QuietHoursStartHour, - QuietHoursEndHour = QuietHoursEndHour, ConfigSyncEnabled = ConfigSyncEnabled, ConfigSyncPath = ConfigSyncPath, ConfigSyncIncludeCredentials = ConfigSyncIncludeCredentials, @@ -496,16 +469,6 @@ private void ReportIssue() private void OpenLogsDirectory() => OpenInShell(System.IO.Path.Combine(_fs.AppDataDir.FullName, "logs")); - // Fire a sample toast straight at the sink (Phase 19.3) so the user can see - // the corner notification without waiting for a real camera event. Bypasses - // the coordinator's cooldown/quiet-hours on purpose — it's a UI preview. - [RelayCommand] - private void TestNotification() => - _notifications.Show(new OpenIPC.Viewer.Core.Notifications.NotificationRequest( - Localizer.Instance["Settings.Notifications.TestTitle"], - Localizer.Instance["Settings.Notifications.TestBody"], - OpenIPC.Viewer.Core.Events.EventKind.Motion)); - // --- Network config auto-sync (Phase 20) ------------------------------ [ObservableProperty] private bool _configSyncEnabled; [ObservableProperty] private string _configSyncPath = ""; diff --git a/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml index 8010d0a..d776309 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml @@ -175,6 +175,19 @@ IsVisible="{Binding !IsWide}" /> + + + + + + + - - - + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +