Complete refactor, reasync, rebuild listener

This commit is contained in:
Alexander Horner
2022-12-06 22:34:32 +00:00
parent 5a4dfc01b6
commit ed5a43a0e8
8 changed files with 234 additions and 206 deletions

View File

@@ -15,33 +15,35 @@ namespace ntfysh_client
{ {
public partial class Form1 : Form 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 = notificationListener;
notificationListener.OnNotificationReceive += OnNotificationReceive; _notificationListener.OnNotificationReceive += OnNotificationReceive;
InitializeComponent(); InitializeComponent();
} }
private void Form1_Load(object sender, EventArgs e) private void Form1_Load(object sender, EventArgs e) => LoadTopics();
{
this.LoadTopics();
}
private void subscribeNewTopic_Click(object sender, EventArgs e) private void subscribeNewTopic_Click(object sender, EventArgs e)
{ {
using (var dialog = new SubscribeDialog(notificationTopics)) using SubscribeDialog dialog = new SubscribeDialog(notificationTopics);
{ DialogResult result = dialog.ShowDialog();
var result = dialog.ShowDialog();
if (result == DialogResult.OK) //Do not subscribe on cancelled dialog
{ if (result != DialogResult.OK) return;
notificationListener.SubscribeToTopic(dialog.getUniqueString(), dialog.getTopicId(), dialog.getServerUrl(), dialog.getUsername(), dialog.getPassword());
notificationTopics.Items.Add(dialog.getUniqueString()); //Subscribe
this.SaveTopicsToFile(); _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) private void removeSelectedTopics_Click(object sender, EventArgs e)
@@ -50,11 +52,11 @@ namespace ntfysh_client
{ {
string topicUniqueString = (string)notificationTopics.Items[notificationTopics.SelectedIndex]; string topicUniqueString = (string)notificationTopics.Items[notificationTopics.SelectedIndex];
notificationListener.RemoveTopicByUniqueString(topicUniqueString); _notificationListener.UnsubscribeFromTopic(topicUniqueString);
notificationTopics.Items.Remove(topicUniqueString); notificationTopics.Items.Remove(topicUniqueString);
} }
this.SaveTopicsToFile(); SaveTopicsToFile();
} }
private void notificationTopics_SelectedValueChanged(object sender, EventArgs e) private void notificationTopics_SelectedValueChanged(object sender, EventArgs e)
@@ -64,51 +66,48 @@ namespace ntfysh_client
private void notificationTopics_Click(object sender, EventArgs e) private void notificationTopics_Click(object sender, EventArgs e)
{ {
var ev = (MouseEventArgs)e; MouseEventArgs ev = (MouseEventArgs)e;
var clickedItemIndex = notificationTopics.IndexFromPoint(new Point(ev.X, ev.Y)); int clickedItemIndex = notificationTopics.IndexFromPoint(new Point(ev.X, ev.Y));
if (clickedItemIndex == -1) if (clickedItemIndex == -1) notificationTopics.ClearSelected();
{
notificationTopics.ClearSelected();
}
} }
private void button1_Click(object sender, EventArgs e) private void button1_Click(object sender, EventArgs e)
{ {
this.Visible = false; Visible = false;
} }
private void notifyIcon_Click(object sender, EventArgs e) private void notifyIcon_Click(object sender, EventArgs e)
{ {
var mouseEv = (MouseEventArgs)e; MouseEventArgs mouseEv = (MouseEventArgs)e;
if (mouseEv.Button == MouseButtons.Left)
{ if (mouseEv.Button != MouseButtons.Left) return;
this.Visible = !this.Visible;
this.BringToFront(); Visible = !Visible;
} BringToFront();
} }
private void showControlWindowToolStripMenuItem_Click(object sender, EventArgs e) private void showControlWindowToolStripMenuItem_Click(object sender, EventArgs e)
{ {
this.Visible = true; Visible = true;
this.BringToFront(); BringToFront();
} }
private string GetTopicsFilePath() 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"); return Path.Combine(binaryDirectory ?? throw new InvalidOperationException("Unable to determine path for topics file"), "topics.json");
} }
private string GetLegacyTopicsFilePath() 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"); return Path.Combine(binaryDirectory ?? throw new InvalidOperationException("Unable to determine path for legacy topics file"), "topics.txt");
} }
private void SaveTopicsToFile() 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); File.WriteAllText(GetTopicsFilePath(), topicsSerialised);
} }
@@ -128,13 +127,13 @@ namespace ntfysh_client
{ {
while (!reader.EndOfStream) while (!reader.EndOfStream)
{ {
string legacyTopic = reader.ReadLine(); string? legacyTopic = reader.ReadLine();
legacyTopics.Add(legacyTopic); if (!string.IsNullOrWhiteSpace(legacyTopic)) legacyTopics.Add(legacyTopic);
} }
} }
//Assemble new format //Assemble new format
List<SubscribedTopic> newTopics = legacyTopics.Select(lt => new SubscribedTopic(lt, "https://ntfy.sh", null, null, null)).ToList(); List<SubscribedTopic> newTopics = legacyTopics.Select(lt => new SubscribedTopic(lt, "https://ntfy.sh", null, null, null, null)).ToList();
string newFormatSerialised = JsonConvert.SerializeObject(newTopics, Formatting.Indented); string newFormatSerialised = JsonConvert.SerializeObject(newTopics, Formatting.Indented);
@@ -160,9 +159,9 @@ namespace ntfysh_client
} }
//Deserialise the topics //Deserialise the topics
List<SubscribedTopic> topics = JsonConvert.DeserializeObject<List<SubscribedTopic>>(topicsSerialised); List<SubscribedTopic>? topics = JsonConvert.DeserializeObject<List<SubscribedTopic>>(topicsSerialised);
if (topics == null) if (topics is null)
{ {
//TODO Deserialise error! //TODO Deserialise error!
return; return;
@@ -171,7 +170,7 @@ namespace ntfysh_client
//Load them in //Load them in
foreach (SubscribedTopic topic in topics) 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}"); notificationTopics.Items.Add($"{topic.TopicId}@{topic.ServerUrl}");
} }
} }
@@ -185,24 +184,22 @@ namespace ntfysh_client
{ {
notifyIcon.Dispose(); notifyIcon.Dispose();
} }
private bool trueExit = false;
private void Form1_FormClosing(object sender, FormClosingEventArgs e) private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{ {
// Let it close // Let it close
if (trueExit) return; if (_trueExit) return;
if (e.CloseReason == CloseReason.UserClosing) if (e.CloseReason != CloseReason.UserClosing) return;
{
this.Visible = false; Visible = false;
e.Cancel = true; e.Cancel = true;
}
} }
private void exitToolStripMenuItem_Click(object sender, EventArgs e) private void exitToolStripMenuItem_Click(object sender, EventArgs e)
{ {
trueExit = true; _trueExit = true;
this.Close(); Close();
} }
private void ntfyshWebsiteToolStripMenuItem_Click(object sender, EventArgs e) private void ntfyshWebsiteToolStripMenuItem_Click(object sender, EventArgs e)
@@ -212,9 +209,8 @@ namespace ntfysh_client
private void aboutToolStripMenuItem_Click(object sender, EventArgs e) private void aboutToolStripMenuItem_Click(object sender, EventArgs e)
{ {
var d = new AboutBox(); using AboutBox d = new AboutBox();
d.ShowDialog(); d.ShowDialog();
d.Dispose();
} }
} }
} }

View File

@@ -7,6 +7,7 @@ using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.WebSockets;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -14,153 +15,147 @@ using System.Web;
namespace ntfysh_client namespace ntfysh_client
{ {
class NotificationListener : IDisposable public class NotificationListener : IDisposable
{ {
private HttpClient httpClient; private readonly HttpClient _httpClient = new();
private bool _isDisposed;
private bool disposedValue;
public readonly Dictionary<string, SubscribedTopic> SubscribedTopicsByUnique = new Dictionary<string, SubscribedTopic>(); public readonly Dictionary<string, SubscribedTopic> SubscribedTopicsByUnique = new();
public delegate void NotificationReceiveHandler(object sender, NotificationReceiveEventArgs e); public delegate void NotificationReceiveHandler(object sender, NotificationReceiveEventArgs e);
public event NotificationReceiveHandler OnNotificationReceive; public event NotificationReceiveHandler? OnNotificationReceive;
public NotificationListener() public NotificationListener()
{ {
httpClient = new HttpClient(); _httpClient.Timeout = TimeSpan.FromMilliseconds(Timeout.Infinite);
httpClient.Timeout = TimeSpan.FromMilliseconds(Timeout.Infinite);
ServicePointManager.DefaultConnectionLimit = 100; 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<string> 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<NtfyEvent>(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(username)) username = null;
if (string.IsNullOrWhiteSpace(password)) password = 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) if (username != null && password != null)
{ {
byte[] boundCredentialsBytes = Encoding.UTF8.GetBytes($"{username}:{password}"); 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)) CancellationTokenSource listenCanceller = new();
{ Task listenTask = ListenToTopicAsync(message, listenCanceller.Token);
using (Stream body = await response.Content.ReadAsStreamAsync())
{
using (StreamReader reader = new StreamReader(body))
{
SubscribedTopicsByUnique.Add(unique, new SubscribedTopic(topicId, serverUrl, username, password, reader));
try SubscribedTopicsByUnique.Add(unique, new SubscribedTopic(topicId, serverUrl, username, password, listenTask, listenCanceller));
{
// The loop will be broken when this stream is closed
while (true)
{
var line = await reader.ReadLineAsync();
Debug.WriteLine(line);
NtfyEventObject nev = JsonConvert.DeserializeObject<NtfyEventObject>(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);
}
}
}
}
}
} }
public void RemoveTopicByUniqueString(string topicUniqueString) public void UnsubscribeFromTopic(string topicUniqueString)
{ {
Debug.WriteLine($"Removing topic {topicUniqueString}"); if (_isDisposed) throw new ObjectDisposedException(nameof(NotificationListener));
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();
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() public void Dispose()
{ {
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method if (_isDisposed) return;
Dispose(disposing: true);
GC.SuppressFinalize(this); _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; }
}
} }

View File

@@ -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;
}
}
}

View File

@@ -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!;
}
}

View File

@@ -8,6 +8,8 @@ namespace ntfysh_client
{ {
static class Program static class Program
{ {
private static readonly NotificationListener NotificationListener = new NotificationListener();
/// <summary> /// <summary>
/// The main entry point for the application. /// The main entry point for the application.
/// </summary> /// </summary>
@@ -16,7 +18,7 @@ namespace ntfysh_client
{ {
Application.EnableVisualStyles(); Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false); Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1()); Application.Run(new Form1(NotificationListener));
} }
} }
} }

View File

@@ -6,6 +6,16 @@ namespace ntfysh_client
public partial class SubscribeDialog : Form public partial class SubscribeDialog : Form
{ {
private readonly ListBox _notificationTopics; 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) public SubscribeDialog(ListBox notificationTopics)
{ {
@@ -13,31 +23,6 @@ namespace ntfysh_client
InitializeComponent(); 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) private void button1_Click(object sender, EventArgs e)
{ {
if (topicId.Text.Length < 1) if (topicId.Text.Length < 1)
@@ -72,7 +57,7 @@ namespace ntfysh_client
return; 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); 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; DialogResult = DialogResult.None;

View File

@@ -1,25 +1,30 @@
using System.IO; using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace ntfysh_client namespace ntfysh_client
{ {
public class SubscribedTopic 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; TopicId = topicId;
ServerUrl = serverUrl; ServerUrl = serverUrl;
Username = username; Username = username;
Password = password; Password = password;
Stream = stream; Runner = runner;
RunnerCanceller = runnerCanceller;
} }
public string TopicId { get; } public string TopicId { get; }
public string ServerUrl { get; } public string ServerUrl { get; }
public string Username { get; } public string? Username { get; }
public string Password { get; } public string? Password { get; }
[JsonIgnore] [JsonIgnore]
public StreamReader Stream { get; } public Task Runner { get; }
[JsonIgnore]
public CancellationTokenSource RunnerCanceller { get; }
} }
} }

View File

@@ -12,6 +12,8 @@
<FileAlignment>512</FileAlignment> <FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic> <Deterministic>true</Deterministic>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget> <PlatformTarget>AnyCPU</PlatformTarget>
@@ -66,6 +68,8 @@
<DependentUpon>Form1.cs</DependentUpon> <DependentUpon>Form1.cs</DependentUpon>
</Compile> </Compile>
<Compile Include="NotificationListener.cs" /> <Compile Include="NotificationListener.cs" />
<Compile Include="NotificationReceiveEventArgs.cs" />
<Compile Include="NtfyEvent.cs" />
<Compile Include="Program.cs" /> <Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" /> <Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="SubscribeDialog.cs"> <Compile Include="SubscribeDialog.cs">