Implement error handling and notification
This commit is contained in:
@@ -19,10 +19,29 @@ namespace ntfysh_client
|
|||||||
{
|
{
|
||||||
_notificationListener = notificationListener;
|
_notificationListener = notificationListener;
|
||||||
_notificationListener.OnNotificationReceive += OnNotificationReceive;
|
_notificationListener.OnNotificationReceive += OnNotificationReceive;
|
||||||
|
_notificationListener.OnConnectionMultiAttemptFailure += OnConnectionMultiAttemptFailure;
|
||||||
|
_notificationListener.OnConnectionCredentialsFailure += OnConnectionCredentialsFailure;
|
||||||
|
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnNotificationReceive(object sender, NotificationReceiveEventArgs e)
|
||||||
|
{
|
||||||
|
notifyIcon.ShowBalloonTip(3000, e.Title, e.Message, ToolTipIcon.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnConnectionMultiAttemptFailure(NotificationListener sender, SubscribedTopic topic)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"Connecting to topic ID '{topic.TopicId}' on server '{topic.ServerUrl}' failed after multiple attempts.\n\nThis topic ID will be ignored and you will not receive notifications for it until you restart the application.", "Connection Failure", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnConnectionCredentialsFailure(NotificationListener sender, SubscribedTopic topic)
|
||||||
|
{
|
||||||
|
string reason = string.IsNullOrWhiteSpace(topic.Username) ? "credentials are required but were not provided" : "the entered credentials are incorrect";
|
||||||
|
|
||||||
|
MessageBox.Show($"Connecting to topic ID '{topic.TopicId}' on server '{topic.ServerUrl}' failed because {reason}.\n\nThis topic ID will be ignored and you will not receive notifications for it until you correct the credentials.", "Connection Authentication Failure", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
}
|
||||||
|
|
||||||
private void MainForm_Load(object sender, EventArgs e) => LoadTopics();
|
private void MainForm_Load(object sender, EventArgs e) => LoadTopics();
|
||||||
|
|
||||||
private void subscribeNewTopic_Click(object sender, EventArgs e)
|
private void subscribeNewTopic_Click(object sender, EventArgs e)
|
||||||
@@ -137,7 +156,7 @@ namespace ntfysh_client
|
|||||||
}
|
}
|
||||||
|
|
||||||
//Assemble new format
|
//Assemble new format
|
||||||
List<SubscribedTopic> newTopics = legacyTopics.Select(lt => new SubscribedTopic(lt, "https://ntfy.sh", null, null, null, null)).ToList();
|
List<SubscribedTopic> newTopics = legacyTopics.Select(lt => new SubscribedTopic(lt, "https://ntfy.sh", null, null)).ToList();
|
||||||
|
|
||||||
string newFormatSerialised = JsonConvert.SerializeObject(newTopics, Formatting.Indented);
|
string newFormatSerialised = JsonConvert.SerializeObject(newTopics, Formatting.Indented);
|
||||||
|
|
||||||
@@ -196,11 +215,6 @@ namespace ntfysh_client
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnNotificationReceive(object sender, NotificationReceiveEventArgs e)
|
|
||||||
{
|
|
||||||
notifyIcon.ShowBalloonTip(3000, e.Title, e.Message, ToolTipIcon.Info);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void MainForm_FormClosed(object sender, FormClosedEventArgs e)
|
private void MainForm_FormClosed(object sender, FormClosedEventArgs e)
|
||||||
{
|
{
|
||||||
notifyIcon.Dispose();
|
notifyIcon.Dispose();
|
||||||
|
@@ -15,33 +15,52 @@ using System.Web;
|
|||||||
|
|
||||||
namespace ntfysh_client
|
namespace ntfysh_client
|
||||||
{
|
{
|
||||||
public class NotificationListener : IDisposable
|
public class NotificationListener
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient = new();
|
|
||||||
private bool _isDisposed;
|
|
||||||
|
|
||||||
public readonly Dictionary<string, SubscribedTopic?> SubscribedTopicsByUnique = new();
|
public readonly Dictionary<string, SubscribedTopic?> SubscribedTopicsByUnique = new();
|
||||||
|
|
||||||
public delegate void NotificationReceiveHandler(object sender, NotificationReceiveEventArgs e);
|
public delegate void NotificationReceiveHandler(NotificationListener sender, NotificationReceiveEventArgs e);
|
||||||
public event NotificationReceiveHandler? OnNotificationReceive;
|
public event NotificationReceiveHandler? OnNotificationReceive;
|
||||||
|
|
||||||
|
public delegate void ConnectionErrorHandler(NotificationListener sender, SubscribedTopic topic);
|
||||||
|
public event ConnectionErrorHandler? OnConnectionMultiAttemptFailure;
|
||||||
|
public event ConnectionErrorHandler? OnConnectionCredentialsFailure;
|
||||||
|
|
||||||
public NotificationListener()
|
public NotificationListener()
|
||||||
{
|
{
|
||||||
_httpClient.Timeout = TimeSpan.FromMilliseconds(Timeout.Infinite);
|
|
||||||
ServicePointManager.DefaultConnectionLimit = 100;
|
ServicePointManager.DefaultConnectionLimit = 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ListenToTopicWithHttpLongJsonAsync(HttpRequestMessage message, CancellationToken cancellationToken)
|
private async Task ListenToTopicWithHttpLongJsonAsync(HttpRequestMessage message, CancellationToken cancellationToken, SubscribedTopic topic)
|
||||||
{
|
{
|
||||||
if (_isDisposed) throw new ObjectDisposedException(nameof(NotificationListener));
|
int connectionAttempts = 0;
|
||||||
|
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
using HttpResponseMessage response = await _httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
//See if we have exceeded maximum attempts
|
||||||
await using Stream body = await response.Content.ReadAsStreamAsync(cancellationToken);
|
if (connectionAttempts >= 10)
|
||||||
|
{
|
||||||
|
//10 connection failures (1 initial + 9 reattempts)! Do not retry
|
||||||
|
OnConnectionMultiAttemptFailure?.Invoke(this, topic);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
//Establish connection
|
||||||
|
using HttpClient client = new();
|
||||||
|
client.Timeout = TimeSpan.FromMilliseconds(Timeout.Infinite); //This will not prevent us from failing to connect, luckily
|
||||||
|
|
||||||
|
using HttpResponseMessage response = await client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
|
await using Stream body = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
|
||||||
|
//Ensure successful connect
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
//Reset connection attempts after a successful connect
|
||||||
|
connectionAttempts = 0;
|
||||||
|
|
||||||
|
//Begin listening
|
||||||
StringBuilder mainBuffer = new();
|
StringBuilder mainBuffer = new();
|
||||||
|
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
@@ -73,32 +92,82 @@ namespace ntfysh_client
|
|||||||
mainBuffer.Append(partialLine);
|
mainBuffer.Append(partialLine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (HttpRequestException hre)
|
||||||
{
|
{
|
||||||
|
if (hre.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
//Our credentials either aren't present when they need to be or are invalid
|
||||||
|
|
||||||
|
//Credential Failure! Do not retry
|
||||||
|
OnConnectionCredentialsFailure?.Invoke(this, topic);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
Debug.WriteLine(ex);
|
Debug.WriteLine(hre);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
//Fall back to the outer loop to restart the listen, or cancel if requested
|
//We will not hit the finally block which will increment the connection failure counter and attempt a reconnect if applicable
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
#if DEBUG
|
||||||
|
Debug.WriteLine(e);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
//We will not hit the finally block which will increment the connection failure counter and attempt a reconnect if applicable
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
//We land here if we fail to connect or our connection gets closed (and if we are canceeling, but that gets ignored)
|
||||||
|
|
||||||
|
if (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
//Not cancelling, legitimate connection failure or termination
|
||||||
|
|
||||||
|
if (connectionAttempts != 0)
|
||||||
|
{
|
||||||
|
//On our first reconnect attempt, try instantly. On consecutive, wait 3 seconds before each attempt
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
//Increment attempts
|
||||||
|
connectionAttempts++;
|
||||||
|
|
||||||
|
//Proceed to reattempt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ListenToTopicWithWebsocketAsync(Uri uri, NetworkCredential credentials, CancellationToken cancellationToken)
|
private async Task ListenToTopicWithWebsocketAsync(Uri uri, NetworkCredential credentials, CancellationToken cancellationToken, SubscribedTopic topic)
|
||||||
{
|
{
|
||||||
if (_isDisposed) throw new ObjectDisposedException(nameof(NotificationListener));
|
int connectionAttempts = 0;
|
||||||
|
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
using ClientWebSocket socket = new();
|
//See if we have exceeded maximum attempts
|
||||||
socket.Options.Credentials = credentials;
|
if (connectionAttempts >= 10)
|
||||||
|
{
|
||||||
|
//10 connection failures (1 initial + 9 reattempts)! Do not retry
|
||||||
|
OnConnectionMultiAttemptFailure?.Invoke(this, topic);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
StringBuilder mainBuffer = new();
|
//Establish connection
|
||||||
|
using ClientWebSocket socket = new();
|
||||||
|
socket.Options.Credentials = credentials;
|
||||||
|
|
||||||
await socket.ConnectAsync(uri, cancellationToken);
|
await socket.ConnectAsync(uri, cancellationToken);
|
||||||
|
|
||||||
|
//Reset connection attempts after a successful connect
|
||||||
|
connectionAttempts = 0;
|
||||||
|
|
||||||
|
//Begin listening
|
||||||
|
StringBuilder mainBuffer = new();
|
||||||
|
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
//Read as much as possible
|
//Read as much as possible
|
||||||
@@ -126,13 +195,50 @@ namespace ntfysh_client
|
|||||||
mainBuffer.Append(partialLine);
|
mainBuffer.Append(partialLine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (WebSocketException wse)
|
||||||
{
|
{
|
||||||
|
if (wse.WebSocketErrorCode is WebSocketError.NotAWebSocket)
|
||||||
|
{
|
||||||
|
//We haven't achieved a connection with a websocket. TODO Seems ntfy doesn't report unauthorised properly, and responds 200
|
||||||
|
|
||||||
|
//Credential Failure! Do not retry
|
||||||
|
OnConnectionCredentialsFailure?.Invoke(this, topic);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
Debug.WriteLine(ex);
|
Debug.WriteLine(wse);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
//Fall back to the outer loop to restart the listen, or cancel if requested
|
//We will not hit the finally block which will increment the connection failure counter and attempt a reconnect if applicable
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
#if DEBUG
|
||||||
|
Debug.WriteLine(e);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
//We will not hit the finally block which will increment the connection failure counter and attempt a reconnect if applicable
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
//We land here if we fail to connect or our connection gets closed (and if we are canceeling, but that gets ignored)
|
||||||
|
|
||||||
|
if (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
//Not cancelling, legitimate connection failure or termination
|
||||||
|
|
||||||
|
if (connectionAttempts != 0)
|
||||||
|
{
|
||||||
|
//On our first reconnect attempt, try instantly. On consecutive, wait 3 seconds before each attempt
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
//Increment attempts
|
||||||
|
connectionAttempts++;
|
||||||
|
|
||||||
|
//Proceed to reattempt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,8 +262,6 @@ namespace ntfysh_client
|
|||||||
|
|
||||||
public void SubscribeToTopicUsingLongHttpJson(string unique, string topicId, string serverUrl, string? username, string? password)
|
public void SubscribeToTopicUsingLongHttpJson(string unique, string topicId, string serverUrl, string? username, string? password)
|
||||||
{
|
{
|
||||||
if (_isDisposed) throw new ObjectDisposedException(nameof(NotificationListener));
|
|
||||||
|
|
||||||
if (SubscribedTopicsByUnique.ContainsKey(unique)) throw new InvalidOperationException("A topic with this unique already exists");
|
if (SubscribedTopicsByUnique.ContainsKey(unique)) throw new InvalidOperationException("A topic with this unique already exists");
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(username)) username = null;
|
if (string.IsNullOrWhiteSpace(username)) username = null;
|
||||||
@@ -172,31 +276,35 @@ namespace ntfysh_client
|
|||||||
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(boundCredentialsBytes));
|
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(boundCredentialsBytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
CancellationTokenSource listenCanceller = new();
|
SubscribedTopic newTopic = new(topicId, serverUrl, username, password);
|
||||||
Task listenTask = ListenToTopicWithHttpLongJsonAsync(message, listenCanceller.Token);
|
|
||||||
|
|
||||||
SubscribedTopicsByUnique.Add(unique, new SubscribedTopic(topicId, serverUrl, username, password, listenTask, listenCanceller));
|
CancellationTokenSource listenCanceller = new();
|
||||||
|
Task listenTask = ListenToTopicWithHttpLongJsonAsync(message, listenCanceller.Token, newTopic);
|
||||||
|
|
||||||
|
newTopic.SetAssociatedRunner(listenTask, listenCanceller);
|
||||||
|
|
||||||
|
SubscribedTopicsByUnique.Add(unique, newTopic);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SubscribeToTopicUsingWebsocket(string unique, string topicId, string serverUrl, string? username, string? password)
|
public void SubscribeToTopicUsingWebsocket(string unique, string topicId, string serverUrl, string? username, string? password)
|
||||||
{
|
{
|
||||||
if (_isDisposed) throw new ObjectDisposedException(nameof(NotificationListener));
|
|
||||||
|
|
||||||
if (SubscribedTopicsByUnique.ContainsKey(unique)) throw new InvalidOperationException("A topic with this unique already exists");
|
if (SubscribedTopicsByUnique.ContainsKey(unique)) throw new InvalidOperationException("A topic with this unique already exists");
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(username)) username = null;
|
if (string.IsNullOrWhiteSpace(username)) username = null;
|
||||||
if (string.IsNullOrWhiteSpace(password)) password = null;
|
if (string.IsNullOrWhiteSpace(password)) password = null;
|
||||||
|
|
||||||
Uri targetUri = new($"{serverUrl}/{HttpUtility.UrlEncode(topicId)}/ws");
|
SubscribedTopic newTopic = new(topicId, serverUrl, username, password);
|
||||||
|
|
||||||
CancellationTokenSource listenCanceller = new();
|
CancellationTokenSource listenCanceller = new();
|
||||||
Task listenTask = ListenToTopicWithWebsocketAsync(targetUri, new NetworkCredential(username, password), listenCanceller.Token);
|
Task listenTask = ListenToTopicWithWebsocketAsync(new Uri($"{serverUrl}/{HttpUtility.UrlEncode(topicId)}/ws"), new NetworkCredential(username, password), listenCanceller.Token, newTopic);
|
||||||
SubscribedTopicsByUnique.Add(unique, new SubscribedTopic(topicId, serverUrl, username, password, listenTask, listenCanceller));
|
|
||||||
|
newTopic.SetAssociatedRunner(listenTask, listenCanceller);
|
||||||
|
|
||||||
|
SubscribedTopicsByUnique.Add(unique, newTopic);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UnsubscribeFromTopicAsync(string topicUniqueString)
|
public async Task UnsubscribeFromTopicAsync(string topicUniqueString)
|
||||||
{
|
{
|
||||||
if (_isDisposed) throw new ObjectDisposedException(nameof(NotificationListener));
|
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
Debug.WriteLine($"Removing topic {topicUniqueString}");
|
Debug.WriteLine($"Removing topic {topicUniqueString}");
|
||||||
#endif
|
#endif
|
||||||
@@ -208,12 +316,12 @@ namespace ntfysh_client
|
|||||||
if (!SubscribedTopicsByUnique.TryGetValue(topicUniqueString, out topic!)) return;
|
if (!SubscribedTopicsByUnique.TryGetValue(topicUniqueString, out topic!)) return;
|
||||||
|
|
||||||
//Cancel and dispose the task runner
|
//Cancel and dispose the task runner
|
||||||
topic.RunnerCanceller.Cancel();
|
topic.RunnerCanceller?.Cancel();
|
||||||
|
|
||||||
//Wait for the task runner to shut down
|
//Wait for the task runner to shut down
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await topic.Runner;
|
if (topic.Runner is not null) await topic.Runner;
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
@@ -221,19 +329,10 @@ namespace ntfysh_client
|
|||||||
}
|
}
|
||||||
|
|
||||||
//Dispose task
|
//Dispose task
|
||||||
topic.Runner.Dispose();
|
topic.Runner?.Dispose();
|
||||||
|
|
||||||
//Remove the old topic
|
//Remove the old topic
|
||||||
SubscribedTopicsByUnique.Remove(topicUniqueString);
|
SubscribedTopicsByUnique.Remove(topicUniqueString);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (_isDisposed) return;
|
|
||||||
|
|
||||||
_httpClient.Dispose();
|
|
||||||
|
|
||||||
_isDisposed = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
using System.Threading;
|
using System;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
@@ -6,12 +7,18 @@ namespace ntfysh_client
|
|||||||
{
|
{
|
||||||
public class SubscribedTopic
|
public class SubscribedTopic
|
||||||
{
|
{
|
||||||
public SubscribedTopic(string topicId, string serverUrl, string? username, string? password, Task runner, CancellationTokenSource runnerCanceller)
|
public SubscribedTopic(string topicId, string serverUrl, string? username, string? password)
|
||||||
{
|
{
|
||||||
TopicId = topicId;
|
TopicId = topicId;
|
||||||
ServerUrl = serverUrl;
|
ServerUrl = serverUrl;
|
||||||
Username = username;
|
Username = username;
|
||||||
Password = password;
|
Password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetAssociatedRunner(Task runner, CancellationTokenSource runnerCanceller)
|
||||||
|
{
|
||||||
|
if (Runner is not null || RunnerCanceller is not null) throw new InvalidOperationException("Runner already associated");
|
||||||
|
|
||||||
Runner = runner;
|
Runner = runner;
|
||||||
RunnerCanceller = runnerCanceller;
|
RunnerCanceller = runnerCanceller;
|
||||||
}
|
}
|
||||||
@@ -22,9 +29,9 @@ namespace ntfysh_client
|
|||||||
public string? Password { get; }
|
public string? Password { get; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public Task Runner { get; }
|
public Task? Runner { get; private set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public CancellationTokenSource RunnerCanceller { get; }
|
public CancellationTokenSource? RunnerCanceller { get; private set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
Reference in New Issue
Block a user