Make fleet alerts readable, deliberate to acknowledge and remembered

Tray 1.6.11.

- One alert with a null or malformed field no longer throws away the whole
  alert list; nullable server fields are tolerated.
- Keystrokes can no longer acknowledge an alert the operator has not seen.
  There is no default button, focus starts on the message, and acknowledging
  takes a mouse click after the alert has been on screen for 1.5 seconds.
- Long messages scroll instead of being cut off, and an ampersand is shown as
  typed.
- A timeout closes the alert without acknowledging it.
- Missing type names, titles and colours fall back to safe defaults.
- Shutdown waits on cancellation instead of sleeping, and closes an open alert
  before the client is disposed.
- The acknowledged-alerts store moved from ProgramData, which the operator
  cannot write, to the user's local app data, guarded by a mutex. Acked alerts
  used to reappear at every logon.
This commit is contained in:
cproudlock
2026-09-15 11:28:52 -04:00
parent 1cf30843f3
commit f484be8a33
4 changed files with 408 additions and 189 deletions

View File

@@ -5,24 +5,37 @@ namespace ShopDbEndpointAgent.Core;
// Local JSON file store for tracking acknowledged fleet alerts.
// Stores notification IDs that have been acknowledged so they don't
// reappear in future polls. Thread-safe file access with JSON serialization.
// reappear in future polls. Lives under %LOCALAPPDATA% because the tray runs
// as the standard operator, who cannot write to %ProgramData%. Read-modify-write
// is serialized across processes by a named mutex.
public sealed class FleetAlertStore
{
private const int MaxIds = 1000;
private const string MutexName = @"Local\ShopDB-FleetAlertStore";
private static readonly TimeSpan MutexWait = TimeSpan.FromSeconds(5);
private static readonly string DataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ShopDB",
"FleetAlerts");
private static readonly string StoreFile = Path.Combine(DataDir, "acknowledged-alerts.json");
// Pre-1.6.11 location. Standard users could not write it, but an elevated
// run may have left IDs there; merged once.
private static readonly string LegacyStoreFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"ShopDB",
"FleetAlerts",
"acknowledged-alerts.json");
private readonly object fileLock = new();
private readonly ILogger logger;
private HashSet<int> acknowledged;
private HashSet<int> acknowledged = new();
public FleetAlertStore(ILogger logger)
{
this.logger = logger;
acknowledged = new HashSet<int>();
Load();
}
@@ -38,11 +51,17 @@ public sealed class FleetAlertStore
{
lock (fileLock)
{
if (acknowledged.Add(notificationId))
WithFileMutex(() =>
{
Save();
PruneOld();
}
// Merge what another instance may have written since our load.
var onDisk = ReadFile(StoreFile);
if (onDisk?.AcknowledgedIds is not null)
acknowledged.UnionWith(onDisk.AcknowledgedIds);
acknowledged.Add(notificationId);
Prune();
Save(onDisk?.MigratedLegacy ?? true);
});
}
}
@@ -50,27 +69,45 @@ public sealed class FleetAlertStore
{
lock (fileLock)
{
try
WithFileMutex(() =>
{
if (!File.Exists(StoreFile))
{
acknowledged = new HashSet<int>();
return;
}
var json = File.ReadAllText(StoreFile);
var stored = JsonSerializer.Deserialize<StoredData>(json);
var stored = ReadFile(StoreFile);
acknowledged = stored?.AcknowledgedIds?.ToHashSet() ?? new HashSet<int>();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not load acknowledged alerts store; starting fresh");
acknowledged = new HashSet<int>();
}
if (stored?.MigratedLegacy == true)
return;
var legacy = ReadFile(LegacyStoreFile);
var merged = legacy?.AcknowledgedIds?.Count(id => acknowledged.Add(id)) ?? 0;
if (merged > 0)
{
logger.LogInformation(
"Merged {Count} acknowledged alert ID(s) from legacy store {Path}.",
merged, LegacyStoreFile);
Prune();
}
if (merged > 0 || stored is not null)
Save(migratedLegacy: true);
});
}
}
private void Save()
private StoredData? ReadFile(string path)
{
try
{
if (!File.Exists(path))
return null;
return JsonSerializer.Deserialize<StoredData>(File.ReadAllText(path));
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not read acknowledged alerts store {Path}.", path);
return null;
}
}
private void Save(bool migratedLegacy)
{
try
{
@@ -79,7 +116,8 @@ public sealed class FleetAlertStore
var data = new StoredData
{
AcknowledgedIds = acknowledged.ToList(),
LastUpdated = DateTime.UtcNow
LastUpdated = DateTime.UtcNow,
MigratedLegacy = migratedLegacy,
};
var json = JsonSerializer.Serialize(data, new JsonSerializerOptions
@@ -87,28 +125,66 @@ public sealed class FleetAlertStore
WriteIndented = true
});
File.WriteAllText(StoreFile, json);
var temp = StoreFile + ".tmp";
File.WriteAllText(temp, json);
File.Move(temp, StoreFile, overwrite: true);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not save acknowledged alerts store");
logger.LogError(ex,
"Could not save acknowledged alerts store {Path}; acknowledged alerts may reappear.",
StoreFile);
}
}
private void PruneOld()
// Runs action under the cross-process mutex. If the mutex cannot be had,
// logs and runs anyway: a rare clobber beats never recording an ack.
private void WithFileMutex(Action action)
{
// Keep only last 1000 acknowledged IDs to prevent unbounded growth
if (acknowledged.Count > 1000)
Mutex? mutex = null;
var owned = false;
try
{
var toKeep = acknowledged.OrderByDescending(id => id).Take(1000).ToHashSet();
acknowledged = toKeep;
Save();
mutex = new Mutex(false, MutexName);
try
{
owned = mutex.WaitOne(MutexWait);
}
catch (AbandonedMutexException)
{
owned = true;
}
if (!owned)
logger.LogWarning("Timed out waiting for fleet alert store mutex; proceeding without it.");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not open fleet alert store mutex; proceeding without it.");
}
try
{
action();
}
finally
{
if (owned)
mutex!.ReleaseMutex();
mutex?.Dispose();
}
}
private void Prune()
{
// Keep only newest IDs to prevent unbounded growth
if (acknowledged.Count > MaxIds)
acknowledged = acknowledged.OrderByDescending(id => id).Take(MaxIds).ToHashSet();
}
private sealed class StoredData
{
public List<int> AcknowledgedIds { get; set; } = new();
public DateTime LastUpdated { get; set; }
public bool MigratedLegacy { get; set; }
}
}

View File

@@ -1,5 +1,6 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using Microsoft.Extensions.Logging;
using ShopDbEndpointAgent.Core;
namespace ShopDbEndpointAgent.Tray;
@@ -8,42 +9,62 @@ namespace ShopDbEndpointAgent.Tray;
// window. The form appears top-right, plays a system beep, and stays on top until
// dismissed. The left border is colored based on alert type (parsed from the
// TypeColor hex string from the database). The operator acknowledges the alert by
// clicking the button or the X. Countdown timer shows auto-dismiss time if configured.
// clicking the button or the X with the mouse. The form steals focus when shown,
// so neither button is a tab stop, there is no Accept/Cancel button, keyboard
// clicks are ignored, and mouse clicks are ignored until ArmDelay has passed:
// keystrokes meant for another app must not acknowledge an unseen alert.
// Closing any other way (server withdrew it, timeout, shutdown) is NOT an
// acknowledgement.
internal sealed class FleetAlertForm : Form
{
private const int MaxMessageLength = 8000;
private const string DefaultTypeName = "Alert";
private const string DefaultTitle = "(no title)";
private static readonly TimeSpan ArmDelay = TimeSpan.FromMilliseconds(1500);
private static readonly Color DefaultTypeColor = Color.Gray;
private readonly FleetAlert alert;
private readonly FleetAlertClient? client;
private readonly string? hostname;
private readonly ILogger? logger;
private System.Windows.Forms.Timer? countdownTimer;
private System.Windows.Forms.Timer? statusCheckTimer;
private readonly CancellationTokenSource statusCheckCancellation = new();
private int remainingSeconds;
private Label? countdownLabel;
private TextBox? messageBox;
private readonly List<Font> fonts = new();
private DateTime armedAtUtc = DateTime.MaxValue;
private bool acknowledged;
public event EventHandler? OnAcknowledgeClicked;
public FleetAlertForm(FleetAlert alert, FleetAlertClient? client = null, string? hostname = null)
public FleetAlertForm(
FleetAlert alert, FleetAlertClient? client = null, string? hostname = null, ILogger? logger = null)
{
this.alert = alert ?? throw new ArgumentNullException(nameof(alert));
this.client = client;
this.hostname = hostname;
this.logger = logger;
SuspendLayout();
AutoScaleDimensions = new SizeF(96F, 96F);
AutoScaleMode = AutoScaleMode.Dpi;
Text = string.Empty;
FormBorderStyle = FormBorderStyle.None;
TopMost = true;
ShowInTaskbar = false;
StartPosition = FormStartPosition.Manual;
ClientSize = new Size(400, 280);
ClientSize = new Size(400, 320);
BackColor = Color.White;
var screen = Screen.PrimaryScreen?.WorkingArea ?? Screen.PrimaryScreen?.Bounds ?? new Rectangle(0, 0, 1920, 1080);
Location = new Point(screen.Right - Width - 20, screen.Top + 20);
KeyPreview = false;
BuildLayout();
ApplyRoundedCorners();
ResumeLayout(false);
Load += FleetAlertForm_Load;
Shown += FleetAlertForm_Shown;
Resize += (s, e) => ApplyRoundedCorners();
FormClosing += FleetAlertForm_FormClosing;
}
@@ -56,6 +77,11 @@ internal sealed class FleetAlertForm : Form
private void FleetAlertForm_Load(object? sender, EventArgs e)
{
// Size is final (DPI scaled) by Load; place and shape here.
var screen = Screen.PrimaryScreen?.WorkingArea ?? Screen.PrimaryScreen?.Bounds ?? new Rectangle(0, 0, 1920, 1080);
Location = new Point(screen.Right - Width - 20, screen.Top + 20);
ApplyRoundedCorners();
System.Media.SystemSounds.Beep.Play();
if (client != null && hostname != null)
@@ -64,6 +90,16 @@ internal sealed class FleetAlertForm : Form
}
}
private void FleetAlertForm_Shown(object? sender, EventArgs e)
{
armedAtUtc = DateTime.UtcNow + ArmDelay;
if (messageBox != null)
{
ActiveControl = messageBox;
messageBox.Select(0, 0);
}
}
private void StartStatusCheck()
{
statusCheckTimer = new System.Windows.Forms.Timer { Interval = 30000 };
@@ -76,15 +112,19 @@ internal sealed class FleetAlertForm : Form
if (client == null || hostname == null)
return;
var token = statusCheckCancellation.Token;
if (token.IsCancellationRequested)
return;
Task.Run(async () =>
{
try
{
var alerts = await client.FetchAlertsAsync(hostname, statusCheckCancellation.Token)
var alerts = await client.FetchAlertsAsync(hostname, token)
.ConfigureAwait(false);
var stillActive = alerts?.Any(a => a.NotificationId == alert.NotificationId) ?? false;
if (!stillActive)
if (!stillActive && !token.IsCancellationRequested && IsHandleCreated && !IsDisposed)
{
BeginInvoke(new Action(() =>
{
@@ -93,13 +133,15 @@ internal sealed class FleetAlertForm : Form
}));
}
}
catch (OperationCanceledException)
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
// Form is closing, ignore
// Form is closing
}
catch
catch (Exception exception)
{
// Ignore errors - keep the form open if we can't check status
// Keep the form open if status cannot be checked
logger?.LogDebug(exception,
"Status check for fleet alert {NotificationId} failed.", alert.NotificationId);
}
});
}
@@ -107,7 +149,7 @@ internal sealed class FleetAlertForm : Form
private void ApplyRoundedCorners()
{
using var path = new GraphicsPath();
int radius = 8;
int radius = LogicalToDeviceUnits(8);
var rect = new Rectangle(0, 0, Width, Height);
path.AddArc(rect.X, rect.Y, radius, radius, 180, 90);
@@ -116,12 +158,20 @@ internal sealed class FleetAlertForm : Form
path.AddArc(rect.X, rect.Bottom - radius, radius, radius, 90, 90);
path.CloseFigure();
var old = Region;
Region = new Region(path);
old?.Dispose();
}
// Not wired (1.6.2 made alerts manual-only). If wired, a timeout closes the
// form WITHOUT acknowledging, so the alert can be surfaced again.
private void StartCountdown()
{
remainingSeconds = alert.Timeout > 0 ? alert.Timeout * 60 : 300;
var minutes = alert.TimeoutMinutes;
if (minutes <= 0)
return;
remainingSeconds = minutes * 60;
countdownTimer = new System.Windows.Forms.Timer { Interval = 1000 };
countdownTimer.Tick += CountdownTimer_Tick;
@@ -138,7 +188,6 @@ internal sealed class FleetAlertForm : Form
if (remainingSeconds <= 0)
{
countdownTimer?.Stop();
OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty);
Close();
}
}
@@ -149,7 +198,7 @@ internal sealed class FleetAlertForm : Form
{
int minutes = remainingSeconds / 60;
int seconds = remainingSeconds % 60;
countdownLabel.Text = $"Auto-dismiss in {minutes}:{seconds:D2}";
countdownLabel.Text = $"Closes in {minutes}:{seconds:D2}";
}
}
@@ -160,8 +209,29 @@ internal sealed class FleetAlertForm : Form
return font;
}
private static string SafeText(string? value, string fallback)
{
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
private static string PrepareMessage(string? value)
{
var text = value ?? string.Empty;
if (text.Length > MaxMessageLength)
text = text.Substring(0, MaxMessageLength) + "...";
return text.Replace("\r\n", "\n").Replace('\r', '\n').Replace("\n", "\r\n");
}
private void BuildLayout()
{
// Dock order: Fill added first, outermost edges added last.
var contentPanel = new Panel
{
Dock = DockStyle.Fill,
BackColor = Color.White,
};
Controls.Add(contentPanel);
var borderPanel = new Panel
{
BackColor = ParseTypeColor(alert.TypeColor),
@@ -170,21 +240,103 @@ internal sealed class FleetAlertForm : Form
};
Controls.Add(borderPanel);
var contentPanel = new Panel
var messagePanel = new Panel
{
Left = 5,
Top = 0,
Width = 395,
Height = 280,
Dock = DockStyle.Fill,
Padding = new Padding(20, 4, 12, 4),
BackColor = Color.White,
};
Controls.Add(contentPanel);
contentPanel.Controls.Add(messagePanel);
messageBox = new TextBox
{
Text = PrepareMessage(alert.Message),
Dock = DockStyle.Fill,
Multiline = true,
ReadOnly = true,
WordWrap = true,
ScrollBars = ScrollBars.Vertical,
BorderStyle = BorderStyle.None,
BackColor = Color.White,
Font = CreateFont("Segoe UI", 9.75f),
ForeColor = Color.FromArgb(85, 85, 85),
TabIndex = 0,
};
messagePanel.Controls.Add(messageBox);
var titleLabel = new Label
{
Text = SafeText(alert.Title, DefaultTitle),
Dock = DockStyle.Top,
Height = 50,
Padding = new Padding(16, 8, 16, 0),
Font = CreateFont("Segoe UI", 11, FontStyle.Bold),
ForeColor = Color.FromArgb(51, 51, 51),
AutoSize = false,
AutoEllipsis = true,
UseMnemonic = false,
};
contentPanel.Controls.Add(titleLabel);
var buttonPanel = new Panel
{
Dock = DockStyle.Bottom,
Height = 46,
BackColor = Color.White,
};
contentPanel.Controls.Add(buttonPanel);
var acknowledgeButton = new MouseOnlyButton(IsArmed)
{
Text = "Acknowledge",
Width = 140,
Height = 32,
Left = (395 - 140) / 2,
Top = 7,
Anchor = AnchorStyles.None,
BackColor = Color.FromArgb(76, 175, 80),
ForeColor = Color.White,
FlatStyle = FlatStyle.Flat,
Font = CreateFont("Segoe UI", 9, FontStyle.Bold),
Cursor = Cursors.Hand,
TabStop = false,
UseMnemonic = false,
};
acknowledgeButton.FlatAppearance.BorderSize = 0;
acknowledgeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(69, 160, 73);
acknowledgeButton.Click += AcknowledgeButton_Click;
buttonPanel.Controls.Add(acknowledgeButton);
var footerPanel = new Panel
{
Dock = DockStyle.Bottom,
Height = 40,
BackColor = Color.White,
};
contentPanel.Controls.Add(footerPanel);
countdownLabel = new Label
{
Text = "Manual acknowledgment required",
Dock = DockStyle.Fill,
TextAlign = ContentAlignment.MiddleCenter,
Font = CreateFont("Segoe UI", 8.25f),
ForeColor = Color.FromArgb(153, 153, 153),
UseMnemonic = false,
};
footerPanel.Controls.Add(countdownLabel);
var footerBorder = new Panel
{
Dock = DockStyle.Top,
Height = 1,
BackColor = Color.FromArgb(238, 238, 238),
};
footerPanel.Controls.Add(footerBorder);
var headerPanel = new Panel
{
Left = 0,
Top = 0,
Width = 395,
Dock = DockStyle.Top,
Height = 45,
BackColor = Color.FromArgb(245, 245, 245),
};
@@ -192,148 +344,66 @@ internal sealed class FleetAlertForm : Form
var typeLabel = new Label
{
Text = alert.TypeName.ToUpper(),
Left = 16,
Top = 14,
Width = 300,
Text = SafeText(alert.TypeName, DefaultTypeName).ToUpperInvariant(),
Dock = DockStyle.Fill,
Padding = new Padding(12, 0, 0, 0),
TextAlign = ContentAlignment.MiddleLeft,
AutoEllipsis = true,
Font = CreateFont("Segoe UI", 10, FontStyle.Bold),
ForeColor = Color.FromArgb(51, 51, 51),
UseMnemonic = false,
};
headerPanel.Controls.Add(typeLabel);
var closeButton = new Button
var closeButton = new MouseOnlyButton(IsArmed)
{
Text = "×",
Left = 358,
Top = 10,
Width = 24,
Height = 24,
Text = "X",
Dock = DockStyle.Right,
Width = 40,
FlatStyle = FlatStyle.Flat,
Font = CreateFont("Segoe UI", 14),
Font = CreateFont("Segoe UI", 11),
ForeColor = Color.FromArgb(102, 102, 102),
BackColor = Color.Transparent,
Cursor = Cursors.Hand,
TabStop = false,
UseMnemonic = false,
};
closeButton.FlatAppearance.BorderSize = 0;
closeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(224, 224, 224);
closeButton.Click += CloseButton_Click;
headerPanel.Controls.Add(closeButton);
var titleLabel = new Label
{
Text = alert.Title,
Left = 20,
Top = 58,
Width = 360,
Height = 50,
Font = CreateFont("Segoe UI", 11, FontStyle.Bold),
ForeColor = Color.FromArgb(51, 51, 51),
AutoSize = false,
};
contentPanel.Controls.Add(titleLabel);
var messageLabel = new Label
{
Text = alert.Message,
Left = 20,
Top = 115,
Width = 360,
Height = 80,
Font = CreateFont("Segoe UI", 9.75f),
ForeColor = Color.FromArgb(85, 85, 85),
AutoSize = false,
};
contentPanel.Controls.Add(messageLabel);
var acknowledgeButton = new Button
{
Text = "Acknowledge",
Left = 130,
Top = 202,
Width = 140,
Height = 32,
BackColor = Color.FromArgb(76, 175, 80),
ForeColor = Color.White,
FlatStyle = FlatStyle.Flat,
Font = CreateFont("Segoe UI", 9, FontStyle.Bold),
Cursor = Cursors.Hand,
};
acknowledgeButton.FlatAppearance.BorderSize = 0;
acknowledgeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(69, 160, 73);
acknowledgeButton.Click += AcknowledgeButton_Click;
contentPanel.Controls.Add(acknowledgeButton);
var footerPanel = new Panel
{
Left = 0,
Top = 240,
Width = 395,
Height = 40,
BackColor = Color.White,
};
contentPanel.Controls.Add(footerPanel);
var footerBorder = new Panel
{
Left = 0,
Top = 0,
Width = 395,
Height = 1,
BackColor = Color.FromArgb(238, 238, 238),
};
footerPanel.Controls.Add(footerBorder);
countdownLabel = new Label
{
Text = "Manual acknowledgment required",
Left = 0,
Top = 12,
Width = 395,
Height = 20,
TextAlign = ContentAlignment.MiddleCenter,
Font = CreateFont("Segoe UI", 8.25f),
ForeColor = Color.FromArgb(153, 153, 153),
};
footerPanel.Controls.Add(countdownLabel);
}
private Color ParseTypeColor(string? hexColor)
private bool IsArmed() => DateTime.UtcNow >= armedAtUtc;
private static Color ParseTypeColor(string? hexColor)
{
if (string.IsNullOrWhiteSpace(hexColor))
return Color.Gray;
return DefaultTypeColor;
var hex = hexColor.TrimStart('#');
if (hex.Length != 6)
return Color.Gray;
var hex = hexColor.Trim().TrimStart('#');
if (hex.Length != 6 || !int.TryParse(hex, System.Globalization.NumberStyles.AllowHexSpecifier,
System.Globalization.CultureInfo.InvariantCulture, out var rgb))
return DefaultTypeColor;
try
{
var r = Convert.ToByte(hex.Substring(0, 2), 16);
var g = Convert.ToByte(hex.Substring(2, 2), 16);
var b = Convert.ToByte(hex.Substring(4, 2), 16);
return Color.FromArgb(r, g, b);
}
catch
{
return Color.Gray;
}
return Color.FromArgb((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF);
}
private void AcknowledgeButton_Click(object? sender, EventArgs e)
private void Acknowledge()
{
if (acknowledged)
return;
acknowledged = true;
countdownTimer?.Stop();
OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty);
DialogResult = DialogResult.OK;
Close();
}
private void CloseButton_Click(object? sender, EventArgs e)
{
countdownTimer?.Stop();
OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty);
DialogResult = DialogResult.OK;
Close();
}
private void AcknowledgeButton_Click(object? sender, EventArgs e) => Acknowledge();
private void CloseButton_Click(object? sender, EventArgs e) => Acknowledge();
protected override void Dispose(bool disposing)
{
@@ -353,4 +423,37 @@ internal sealed class FleetAlertForm : Form
}
base.Dispose(disposing);
}
// Raises Click only for a left mouse release on the button once armed.
// Space, Enter, mnemonics and PerformClick all route through OnClick
// without passing OnMouseUp, so they are dropped.
private sealed class MouseOnlyButton : Button
{
private readonly Func<bool> isArmed;
private bool inMouseUp;
public MouseOnlyButton(Func<bool> isArmed)
{
this.isArmed = isArmed;
}
protected override void OnMouseUp(MouseEventArgs mevent)
{
inMouseUp = true;
try
{
base.OnMouseUp(mevent);
}
finally
{
inMouseUp = false;
}
}
protected override void OnClick(EventArgs e)
{
if (inMouseUp && isArmed())
base.OnClick(e);
}
}
}

View File

@@ -21,6 +21,9 @@ internal sealed class FleetAlertWorker : IDisposable
private readonly CancellationTokenSource cancellation;
private readonly Thread workerThread;
// Set and cleared on the UI thread only.
private FleetAlertForm? openForm;
public FleetAlertWorker(
FleetAlertClient client,
FleetAlertStore store,
@@ -55,14 +58,8 @@ internal sealed class FleetAlertWorker : IDisposable
private void Run()
{
var token = cancellation.Token;
try
{
Thread.Sleep(StartupDelay);
}
catch (Exception)
{
if (token.WaitHandle.WaitOne(StartupDelay))
return;
}
while (!token.IsCancellationRequested)
{
@@ -70,7 +67,7 @@ internal sealed class FleetAlertWorker : IDisposable
{
PollAndDisplayAlerts(token);
}
catch (OperationCanceledException)
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
return;
}
@@ -79,20 +76,20 @@ internal sealed class FleetAlertWorker : IDisposable
logger.LogWarning(exception, "Fleet alert poll failed; retrying on next cycle.");
}
try
{
Thread.Sleep(PollInterval);
}
catch (Exception)
{
if (token.WaitHandle.WaitOne(PollInterval))
return;
}
}
}
private void PollAndDisplayAlerts(CancellationToken token)
{
var alerts = client.FetchAlertsAsync(hostname, token).GetAwaiter().GetResult();
if (alerts is FleetAlertList { Skipped.Count: > 0 } parsed)
{
logger.LogWarning(
"Skipped {Count} malformed fleet alert(s): {Details}",
parsed.Skipped.Count, string.Join("; ", parsed.Skipped));
}
if (alerts is null || alerts.Count == 0)
{
logger.LogDebug("No active fleet alerts.");
@@ -129,9 +126,20 @@ internal sealed class FleetAlertWorker : IDisposable
{
try
{
using var form = new FleetAlertForm(alert, client, hostname);
if (token.IsCancellationRequested)
return;
using var form = new FleetAlertForm(alert, client, hostname, logger);
form.OnAcknowledgeClicked += (s, e) => OnAlertAcknowledged(alert, token);
var result = form.ShowDialog();
openForm = form;
try
{
form.ShowDialog();
}
finally
{
openForm = null;
}
}
catch (Exception exception)
{
@@ -180,9 +188,31 @@ internal sealed class FleetAlertWorker : IDisposable
});
}
// Closes a displayed alert without acknowledging it. Call on the UI thread
// before disposing the client, so the form's status check stops using it.
public void CloseOpenAlert()
{
var form = openForm;
if (form is null || form.IsDisposed)
return;
try
{
if (form.InvokeRequired)
form.BeginInvoke(new Action(form.Close));
else
form.Close();
}
catch (Exception exception)
{
logger.LogWarning(exception, "Could not close open fleet alert on shutdown.");
}
}
public void Dispose()
{
cancellation.Cancel();
CloseOpenAlert();
if (workerThread.IsAlive)
{

View File

@@ -96,7 +96,17 @@
No tray code changes, but deployed together for version consistency. -->
<!-- 1.6.10: coordinated release with agent 1.18.0 (fingerprint computation).
No tray code changes, but deployed together for version consistency. -->
<Version>1.6.10</Version>
<!-- 1.6.11: fleet alert hardening. The toast steals focus, so its buttons are
no longer tab stops, there is no Accept/Cancel button, and acknowledge
needs a mouse click at least 1.5s after the form is shown: keystrokes
meant for another app cannot acknowledge an unseen alert. Long messages
scroll instead of being cut off and '&' is shown literally. A null or
malformed alert field no longer throws away the whole poll. A timeout
closes without acknowledging. The acknowledged store moves to
%LOCALAPPDATA%\ShopDB\FleetAlerts (the operator could not write the
ProgramData copy, so acks were lost every logon), merging the old file
once. Shutdown no longer blocks on the poller's sleeps. -->
<Version>1.6.11</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>