From ed5a43a0e8ac014f0df8dae8d4e040325c326b52 Mon Sep 17 00:00:00 2001 From: Alexander Horner <33007665+alexhorner@users.noreply.github.com> Date: Tue, 6 Dec 2022 22:34:32 +0000 Subject: [PATCH] Complete refactor, reasync, rebuild listener --- ntfysh_client/Form1.cs | 108 ++++----- ntfysh_client/NotificationListener.cs | 229 +++++++++--------- ntfysh_client/NotificationReceiveEventArgs.cs | 16 ++ ntfysh_client/NtfyEvent.cs | 25 ++ ntfysh_client/Program.cs | 4 +- ntfysh_client/SubscribeDialog.cs | 37 +-- ntfysh_client/SubscribedTopic.cs | 17 +- ntfysh_client/ntfysh_client.csproj | 4 + 8 files changed, 234 insertions(+), 206 deletions(-) create mode 100644 ntfysh_client/NotificationReceiveEventArgs.cs create mode 100644 ntfysh_client/NtfyEvent.cs diff --git a/ntfysh_client/Form1.cs b/ntfysh_client/Form1.cs index 6c3b9e1..5666191 100644 --- a/ntfysh_client/Form1.cs +++ b/ntfysh_client/Form1.cs @@ -15,33 +15,35 @@ namespace ntfysh_client { public partial class Form1 : Form { - private NotificationListener notificationListener; + private readonly NotificationListener _notificationListener; + private bool _trueExit; - public Form1() + public Form1(NotificationListener notificationListener) { - notificationListener = new NotificationListener(); - notificationListener.OnNotificationReceive += OnNotificationReceive; + _notificationListener = notificationListener; + _notificationListener.OnNotificationReceive += OnNotificationReceive; + InitializeComponent(); } - private void Form1_Load(object sender, EventArgs e) - { - this.LoadTopics(); - } + private void Form1_Load(object sender, EventArgs e) => LoadTopics(); private void subscribeNewTopic_Click(object sender, EventArgs e) { - using (var dialog = new SubscribeDialog(notificationTopics)) - { - var result = dialog.ShowDialog(); + using SubscribeDialog dialog = new SubscribeDialog(notificationTopics); + DialogResult result = dialog.ShowDialog(); - if (result == DialogResult.OK) - { - notificationListener.SubscribeToTopic(dialog.getUniqueString(), dialog.getTopicId(), dialog.getServerUrl(), dialog.getUsername(), dialog.getPassword()); - notificationTopics.Items.Add(dialog.getUniqueString()); - this.SaveTopicsToFile(); - } - } + //Do not subscribe on cancelled dialog + if (result != DialogResult.OK) return; + + //Subscribe + _notificationListener.SubscribeToTopicUsingLongHttpJson(dialog.Unique, dialog.TopicId, dialog.ServerUrl, dialog.Username, dialog.Password); + + //Add to the user visible list + notificationTopics.Items.Add(dialog.Unique); + + //Save the topics persistently + SaveTopicsToFile(); } private void removeSelectedTopics_Click(object sender, EventArgs e) @@ -50,11 +52,11 @@ namespace ntfysh_client { string topicUniqueString = (string)notificationTopics.Items[notificationTopics.SelectedIndex]; - notificationListener.RemoveTopicByUniqueString(topicUniqueString); + _notificationListener.UnsubscribeFromTopic(topicUniqueString); notificationTopics.Items.Remove(topicUniqueString); } - this.SaveTopicsToFile(); + SaveTopicsToFile(); } private void notificationTopics_SelectedValueChanged(object sender, EventArgs e) @@ -64,51 +66,48 @@ namespace ntfysh_client private void notificationTopics_Click(object sender, EventArgs e) { - var ev = (MouseEventArgs)e; - var clickedItemIndex = notificationTopics.IndexFromPoint(new Point(ev.X, ev.Y)); + MouseEventArgs ev = (MouseEventArgs)e; + int clickedItemIndex = notificationTopics.IndexFromPoint(new Point(ev.X, ev.Y)); - if (clickedItemIndex == -1) - { - notificationTopics.ClearSelected(); - } + if (clickedItemIndex == -1) notificationTopics.ClearSelected(); } private void button1_Click(object sender, EventArgs e) { - this.Visible = false; + Visible = false; } private void notifyIcon_Click(object sender, EventArgs e) { - var mouseEv = (MouseEventArgs)e; - if (mouseEv.Button == MouseButtons.Left) - { - this.Visible = !this.Visible; - this.BringToFront(); - } + MouseEventArgs mouseEv = (MouseEventArgs)e; + + if (mouseEv.Button != MouseButtons.Left) return; + + Visible = !Visible; + BringToFront(); } private void showControlWindowToolStripMenuItem_Click(object sender, EventArgs e) { - this.Visible = true; - this.BringToFront(); + Visible = true; + BringToFront(); } private string GetTopicsFilePath() { - string binaryDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + string binaryDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? throw new InvalidOperationException("Unable to determine path for application"); return Path.Combine(binaryDirectory ?? throw new InvalidOperationException("Unable to determine path for topics file"), "topics.json"); } private string GetLegacyTopicsFilePath() { - string binaryDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + string binaryDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? throw new InvalidOperationException("Unable to determine path for application"); return Path.Combine(binaryDirectory ?? throw new InvalidOperationException("Unable to determine path for legacy topics file"), "topics.txt"); } private void SaveTopicsToFile() { - string topicsSerialised = JsonConvert.SerializeObject(notificationListener.SubscribedTopicsByUnique.Select(st => st.Value).ToList(), Formatting.Indented); + string topicsSerialised = JsonConvert.SerializeObject(_notificationListener.SubscribedTopicsByUnique.Select(st => st.Value).ToList(), Formatting.Indented); File.WriteAllText(GetTopicsFilePath(), topicsSerialised); } @@ -128,13 +127,13 @@ namespace ntfysh_client { while (!reader.EndOfStream) { - string legacyTopic = reader.ReadLine(); - legacyTopics.Add(legacyTopic); + string? legacyTopic = reader.ReadLine(); + if (!string.IsNullOrWhiteSpace(legacyTopic)) legacyTopics.Add(legacyTopic); } } //Assemble new format - List newTopics = legacyTopics.Select(lt => new SubscribedTopic(lt, "https://ntfy.sh", null, null, null)).ToList(); + List newTopics = legacyTopics.Select(lt => new SubscribedTopic(lt, "https://ntfy.sh", null, null, null, null)).ToList(); string newFormatSerialised = JsonConvert.SerializeObject(newTopics, Formatting.Indented); @@ -160,9 +159,9 @@ namespace ntfysh_client } //Deserialise the topics - List topics = JsonConvert.DeserializeObject>(topicsSerialised); + List? topics = JsonConvert.DeserializeObject>(topicsSerialised); - if (topics == null) + if (topics is null) { //TODO Deserialise error! return; @@ -171,7 +170,7 @@ namespace ntfysh_client //Load them in foreach (SubscribedTopic topic in topics) { - notificationListener.SubscribeToTopic($"{topic.TopicId}@{topic.ServerUrl}", topic.TopicId, topic.ServerUrl, topic.Username, topic.Password); + _notificationListener.SubscribeToTopicUsingLongHttpJson($"{topic.TopicId}@{topic.ServerUrl}", topic.TopicId, topic.ServerUrl, topic.Username, topic.Password); notificationTopics.Items.Add($"{topic.TopicId}@{topic.ServerUrl}"); } } @@ -185,24 +184,22 @@ namespace ntfysh_client { notifyIcon.Dispose(); } - - private bool trueExit = false; + private void Form1_FormClosing(object sender, FormClosingEventArgs e) { // Let it close - if (trueExit) return; + if (_trueExit) return; - if (e.CloseReason == CloseReason.UserClosing) - { - this.Visible = false; - e.Cancel = true; - } + if (e.CloseReason != CloseReason.UserClosing) return; + + Visible = false; + e.Cancel = true; } private void exitToolStripMenuItem_Click(object sender, EventArgs e) { - trueExit = true; - this.Close(); + _trueExit = true; + Close(); } private void ntfyshWebsiteToolStripMenuItem_Click(object sender, EventArgs e) @@ -212,9 +209,8 @@ namespace ntfysh_client private void aboutToolStripMenuItem_Click(object sender, EventArgs e) { - var d = new AboutBox(); + using AboutBox d = new AboutBox(); d.ShowDialog(); - d.Dispose(); } } } diff --git a/ntfysh_client/NotificationListener.cs b/ntfysh_client/NotificationListener.cs index a7057ca..408c274 100644 --- a/ntfysh_client/NotificationListener.cs +++ b/ntfysh_client/NotificationListener.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Net.WebSockets; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -14,153 +15,147 @@ using System.Web; namespace ntfysh_client { - class NotificationListener : IDisposable + public class NotificationListener : IDisposable { - private HttpClient httpClient; - - private bool disposedValue; + private readonly HttpClient _httpClient = new(); + private bool _isDisposed; - public readonly Dictionary SubscribedTopicsByUnique = new Dictionary(); + public readonly Dictionary SubscribedTopicsByUnique = new(); public delegate void NotificationReceiveHandler(object sender, NotificationReceiveEventArgs e); - public event NotificationReceiveHandler OnNotificationReceive; + public event NotificationReceiveHandler? OnNotificationReceive; public NotificationListener() { - httpClient = new HttpClient(); - - httpClient.Timeout = TimeSpan.FromMilliseconds(Timeout.Infinite); + _httpClient.Timeout = TimeSpan.FromMilliseconds(Timeout.Infinite); ServicePointManager.DefaultConnectionLimit = 100; } - public async Task SubscribeToTopic(string unique, string topicId, string serverUrl, string username, string password) + private async Task ListenToTopicAsync(HttpRequestMessage message, CancellationToken cancellationToken) { + if (_isDisposed) throw new ObjectDisposedException(nameof(NotificationListener)); + + while (!cancellationToken.IsCancellationRequested) + { + using HttpResponseMessage response = await _httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + using Stream body = await response.Content.ReadAsStreamAsync(); + + try + { + StringBuilder mainBuffer = new(); + + while (!cancellationToken.IsCancellationRequested) + { + //Read as much as possible + byte[] buffer = new byte[8192]; + int readBytes = await body.ReadAsync(buffer, 0, buffer.Length, cancellationToken); + + //Append it to our main buffer + mainBuffer.Append(Encoding.UTF8.GetString(buffer, 0, readBytes)); + + List lines = mainBuffer.ToString().Split('\n').ToList(); + + //If we have not yet received a full line, meaning theres only 1 part, go back to reading + if (lines.Count <= 1) continue; + + //We now have at least 1 line! Count how many full lines. There will always be a partial line at the end, even if that partial line is empty + + //Separate the partial line from the full lines + int partialLineIndex = lines.Count - 1; + string partialLine = lines[partialLineIndex]; + lines.RemoveAt(partialLineIndex); + + //Process the full lines + foreach (string line in lines) ProcessMessage(line); + + //Write back the partial line + mainBuffer.Clear(); + mainBuffer.Append(partialLine); + } + } + catch (Exception ex) + { + #if DEBUG + Debug.WriteLine(ex); + #endif + + //Fall back to the outer loop to restart the listen, or cancel if requested + } + } + } + + private void ProcessMessage(string message) + { + #if DEBUG + Debug.WriteLine(message); + #endif + + NtfyEvent? evt = JsonConvert.DeserializeObject(message); + + //If we hit this, ntfy sent us an invalid message + if (evt is null) return; + + if (evt.Event == "message") + { + OnNotificationReceive?.Invoke(this, new NotificationReceiveEventArgs(evt.Title, evt.Message)); + } + } + + 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 (string.IsNullOrWhiteSpace(username)) username = null; if (string.IsNullOrWhiteSpace(password)) password = null; - HttpRequestMessage msg = new HttpRequestMessage(HttpMethod.Get, $"{serverUrl}/{HttpUtility.UrlEncode(topicId)}/json"); + HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Get, $"{serverUrl}/{HttpUtility.UrlEncode(topicId)}/json"); if (username != null && password != null) { byte[] boundCredentialsBytes = Encoding.UTF8.GetBytes($"{username}:{password}"); - msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(boundCredentialsBytes)); + message.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(boundCredentialsBytes)); } - using (HttpResponseMessage response = await httpClient.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead)) - { - using (Stream body = await response.Content.ReadAsStreamAsync()) - { - using (StreamReader reader = new StreamReader(body)) - { - SubscribedTopicsByUnique.Add(unique, new SubscribedTopic(topicId, serverUrl, username, password, reader)); + CancellationTokenSource listenCanceller = new(); + Task listenTask = ListenToTopicAsync(message, listenCanceller.Token); - try - { - // The loop will be broken when this stream is closed - while (true) - { - var line = await reader.ReadLineAsync(); - - Debug.WriteLine(line); - - NtfyEventObject nev = JsonConvert.DeserializeObject(line); - - if (nev.Event == "message") - { - if (OnNotificationReceive != null) - { - var evArgs = new NotificationReceiveEventArgs(nev.Title, nev.Message); - OnNotificationReceive(this, evArgs); - } - } - } - } - catch (Exception ex) - { - Debug.WriteLine(ex); - - // If the topic is still registered, then that stream wasn't mean to be closed (maybe network failure?) - // Restart it - if (SubscribedTopicsByUnique.ContainsKey(unique)) - { - SubscribeToTopic(unique, topicId, serverUrl, username, password); - } - } - } - } - } + SubscribedTopicsByUnique.Add(unique, new SubscribedTopic(topicId, serverUrl, username, password, listenTask, listenCanceller)); } - public void RemoveTopicByUniqueString(string topicUniqueString) + public void UnsubscribeFromTopic(string topicUniqueString) { - Debug.WriteLine($"Removing topic {topicUniqueString}"); - - if (SubscribedTopicsByUnique.ContainsKey(topicUniqueString)) - { - // Not moronic to store it in a variable; this solves a race condition in SubscribeToTopic - SubscribedTopic topic = SubscribedTopicsByUnique[topicUniqueString]; - topic.Stream.Close(); + if (_isDisposed) throw new ObjectDisposedException(nameof(NotificationListener)); - SubscribedTopicsByUnique.Remove(topicUniqueString); - } + #if DEBUG + Debug.WriteLine($"Removing topic {topicUniqueString}"); + #endif + + //Topic isn't even subscribed, ignore + if (!SubscribedTopicsByUnique.TryGetValue(topicUniqueString, out SubscribedTopic topic)) return; + + //Cancel and dispose the task runner + topic.RunnerCanceller.Cancel(); + + //Wait for the task runner to shut down + while (!topic.Runner.IsCompleted) Thread.Sleep(100); + + //Dispose task + topic.Runner.Dispose(); + + //Remove the old topic + SubscribedTopicsByUnique.Remove(topicUniqueString); } - protected virtual void Dispose(bool disposing) - { - if (!disposedValue) - { - if (disposing) - { - // TODO: dispose managed state (managed objects) - } - - // TODO: free unmanaged resources (unmanaged objects) and override finalizer - // TODO: set large fields to null - disposedValue = true; - } - } - - // // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources - // ~NotificationListener() - // { - // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - // Dispose(disposing: false); - // } - public void Dispose() { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose(disposing: true); - GC.SuppressFinalize(this); + if (_isDisposed) return; + + _httpClient.Dispose(); + + _isDisposed = true; } } - - public class NotificationReceiveEventArgs : EventArgs - { - public string Title { get; private set; } - public string Message { get; private set; } - - public NotificationReceiveEventArgs(string title, string message) - { - Title = title; - Message = message; - } - } - - public class NtfyEventObject - { - [JsonProperty("id")] - public string Id { get; set; } - [JsonProperty("time")] - public Int64 Time { get; set; } - [JsonProperty("event")] - public string Event { get; set; } - [JsonProperty("topic")] - public string Topic { get; set; } - [JsonProperty("message")] - public string Message { get; set; } - [JsonProperty("title")] - public string Title { get; set; } - } } diff --git a/ntfysh_client/NotificationReceiveEventArgs.cs b/ntfysh_client/NotificationReceiveEventArgs.cs new file mode 100644 index 0000000..87d932e --- /dev/null +++ b/ntfysh_client/NotificationReceiveEventArgs.cs @@ -0,0 +1,16 @@ +using System; + +namespace ntfysh_client +{ + public class NotificationReceiveEventArgs : EventArgs + { + public string Title { get; } + public string Message { get; } + + public NotificationReceiveEventArgs(string title, string message) + { + Title = title; + Message = message; + } + } +} \ No newline at end of file diff --git a/ntfysh_client/NtfyEvent.cs b/ntfysh_client/NtfyEvent.cs new file mode 100644 index 0000000..ef4ee82 --- /dev/null +++ b/ntfysh_client/NtfyEvent.cs @@ -0,0 +1,25 @@ +using Newtonsoft.Json; + +namespace ntfysh_client +{ + public class NtfyEvent + { + [JsonProperty("id")] + public string Id { get; set; } = null!; + + [JsonProperty("time")] + public long Time { get; set; } + + [JsonProperty("event")] + public string Event { get; set; } = null!; + + [JsonProperty("topic")] + public string Topic { get; set; } = null!; + + [JsonProperty("message")] + public string Message { get; set; } = null!; + + [JsonProperty("title")] + public string Title { get; set; } = null!; + } +} \ No newline at end of file diff --git a/ntfysh_client/Program.cs b/ntfysh_client/Program.cs index d9332fa..a980f95 100644 --- a/ntfysh_client/Program.cs +++ b/ntfysh_client/Program.cs @@ -8,6 +8,8 @@ namespace ntfysh_client { static class Program { + private static readonly NotificationListener NotificationListener = new NotificationListener(); + /// /// The main entry point for the application. /// @@ -16,7 +18,7 @@ namespace ntfysh_client { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); - Application.Run(new Form1()); + Application.Run(new Form1(NotificationListener)); } } } diff --git a/ntfysh_client/SubscribeDialog.cs b/ntfysh_client/SubscribeDialog.cs index f5c697e..e5233f6 100644 --- a/ntfysh_client/SubscribeDialog.cs +++ b/ntfysh_client/SubscribeDialog.cs @@ -6,6 +6,16 @@ namespace ntfysh_client public partial class SubscribeDialog : Form { private readonly ListBox _notificationTopics; + + public string TopicId => topicId.Text; + + public string ServerUrl => serverUrl.Text; + + public string Username => username.Text; + + public string Password => password.Text; + + public string Unique => $"{topicId.Text}@{serverUrl.Text}"; public SubscribeDialog(ListBox notificationTopics) { @@ -13,31 +23,6 @@ namespace ntfysh_client InitializeComponent(); } - public string getTopicId() - { - return topicId.Text; - } - - public string getServerUrl() - { - return serverUrl.Text; - } - - public string getUsername() - { - return username.Text; - } - - public string getPassword() - { - return password.Text; - } - - public string getUniqueString() - { - return $"{topicId.Text}@{serverUrl.Text}"; - } - private void button1_Click(object sender, EventArgs e) { if (topicId.Text.Length < 1) @@ -72,7 +57,7 @@ namespace ntfysh_client return; } - if (_notificationTopics.Items.Contains(getUniqueString())) + if (_notificationTopics.Items.Contains(Unique)) { MessageBox.Show($"The specified topic '{topicId.Text}' on the server '{serverUrl.Text}' is already subscribed", "Topic already subscribed", MessageBoxButtons.OK, MessageBoxIcon.Error); DialogResult = DialogResult.None; diff --git a/ntfysh_client/SubscribedTopic.cs b/ntfysh_client/SubscribedTopic.cs index 184f768..f5b172b 100644 --- a/ntfysh_client/SubscribedTopic.cs +++ b/ntfysh_client/SubscribedTopic.cs @@ -1,25 +1,30 @@ -using System.IO; +using System.Threading; +using System.Threading.Tasks; using Newtonsoft.Json; namespace ntfysh_client { public class SubscribedTopic { - public SubscribedTopic(string topicId, string serverUrl, string username, string password, StreamReader stream) + public SubscribedTopic(string topicId, string serverUrl, string? username, string? password, Task runner, CancellationTokenSource runnerCanceller) { TopicId = topicId; ServerUrl = serverUrl; Username = username; Password = password; - Stream = stream; + Runner = runner; + RunnerCanceller = runnerCanceller; } public string TopicId { get; } public string ServerUrl { get; } - public string Username { get; } - public string Password { get; } + public string? Username { get; } + public string? Password { get; } [JsonIgnore] - public StreamReader Stream { get; } + public Task Runner { get; } + + [JsonIgnore] + public CancellationTokenSource RunnerCanceller { get; } } } \ No newline at end of file diff --git a/ntfysh_client/ntfysh_client.csproj b/ntfysh_client/ntfysh_client.csproj index ad03786..3708738 100644 --- a/ntfysh_client/ntfysh_client.csproj +++ b/ntfysh_client/ntfysh_client.csproj @@ -12,6 +12,8 @@ 512 true true + latest + enable AnyCPU @@ -66,6 +68,8 @@ Form1.cs + +