From 1e1187e71ff99d3e70b33b3501c6b78ab2b6c24e Mon Sep 17 00:00:00 2001 From: Alexander Horner <33007665+alexhorner@users.noreply.github.com> Date: Wed, 7 Dec 2022 21:36:03 +0000 Subject: [PATCH] Basic Websocket support working, without authentication (auth broken) --- ntfysh_client/MainForm.cs | 30 +++++- ntfysh_client/NotificationListener.cs | 72 ++++++++++++- ntfysh_client/SubscribeDialog.Designer.cs | 94 ++++++++++++----- ntfysh_client/SubscribeDialog.cs | 122 +++++++++++++++++++++- ntfysh_client/SubscribeDialog.resx | 62 +---------- 5 files changed, 286 insertions(+), 94 deletions(-) diff --git a/ntfysh_client/MainForm.cs b/ntfysh_client/MainForm.cs index 296c664..acf5b99 100644 --- a/ntfysh_client/MainForm.cs +++ b/ntfysh_client/MainForm.cs @@ -34,8 +34,15 @@ namespace ntfysh_client if (result != DialogResult.OK) return; //Subscribe - _notificationListener.SubscribeToTopicUsingLongHttpJson(dialog.Unique, dialog.TopicId, dialog.ServerUrl, dialog.Username, dialog.Password); - + if (dialog.UseWebsockets) + { + _notificationListener.SubscribeToTopicUsingWebsocket(dialog.Unique, dialog.TopicId, dialog.ServerUrl, dialog.Username, dialog.Password); + } + else + { + _notificationListener.SubscribeToTopicUsingLongHttpJson(dialog.Unique, dialog.TopicId, dialog.ServerUrl, dialog.Username, dialog.Password); + } + //Add to the user visible list notificationTopics.Items.Add(dialog.Unique); @@ -167,7 +174,24 @@ namespace ntfysh_client //Load them in foreach (SubscribedTopic topic in topics) { - _notificationListener.SubscribeToTopicUsingLongHttpJson($"{topic.TopicId}@{topic.ServerUrl}", topic.TopicId, topic.ServerUrl, topic.Username, topic.Password); + string[] parts = topic.ServerUrl.Split("://", 2); + + switch (parts[0].ToLower()) + { + case "ws": + case "wss": + _notificationListener.SubscribeToTopicUsingWebsocket($"{topic.TopicId}@{topic.ServerUrl}", topic.TopicId, topic.ServerUrl, topic.Username, topic.Password); + break; + + case "http": + case "https": + _notificationListener.SubscribeToTopicUsingLongHttpJson($"{topic.TopicId}@{topic.ServerUrl}", topic.TopicId, topic.ServerUrl, topic.Username, topic.Password); + break; + + default: + continue; + } + notificationTopics.Items.Add($"{topic.TopicId}@{topic.ServerUrl}"); } } diff --git a/ntfysh_client/NotificationListener.cs b/ntfysh_client/NotificationListener.cs index 5a08f70..9995599 100644 --- a/ntfysh_client/NotificationListener.cs +++ b/ntfysh_client/NotificationListener.cs @@ -31,7 +31,7 @@ namespace ntfysh_client ServicePointManager.DefaultConnectionLimit = 100; } - private async Task ListenToTopicAsync(HttpRequestMessage message, CancellationToken cancellationToken) + private async Task ListenToTopicWithHttpLongJsonAsync(HttpRequestMessage message, CancellationToken cancellationToken) { if (_isDisposed) throw new ObjectDisposedException(nameof(NotificationListener)); @@ -83,6 +83,59 @@ namespace ntfysh_client } } } + + private async Task ListenToTopicWithWebsocketAsync(Uri uri, NetworkCredential credentials, CancellationToken cancellationToken) + { + if (_isDisposed) throw new ObjectDisposedException(nameof(NotificationListener)); + + while (!cancellationToken.IsCancellationRequested) + { + using ClientWebSocket socket = new(); + socket.Options.Credentials = credentials; + + try + { + StringBuilder mainBuffer = new(); + + await socket.ConnectAsync(uri, cancellationToken); + + while (!cancellationToken.IsCancellationRequested) + { + //Read as much as possible + byte[] buffer = new byte[8192]; + WebSocketReceiveResult? result = await socket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + + //Append it to our main buffer + mainBuffer.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); + + 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) { @@ -120,10 +173,25 @@ namespace ntfysh_client } CancellationTokenSource listenCanceller = new(); - Task listenTask = ListenToTopicAsync(message, listenCanceller.Token); + Task listenTask = ListenToTopicWithHttpLongJsonAsync(message, listenCanceller.Token); SubscribedTopicsByUnique.Add(unique, new SubscribedTopic(topicId, serverUrl, username, password, listenTask, listenCanceller)); } + + 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 (string.IsNullOrWhiteSpace(username)) username = null; + if (string.IsNullOrWhiteSpace(password)) password = null; + + Uri targetUri = new($"{serverUrl}/{HttpUtility.UrlEncode(topicId)}/ws"); + CancellationTokenSource listenCanceller = new(); + Task listenTask = ListenToTopicWithWebsocketAsync(targetUri, new NetworkCredential(username, password), listenCanceller.Token); + SubscribedTopicsByUnique.Add(unique, new SubscribedTopic(topicId, serverUrl, username, password, listenTask, listenCanceller)); + } public async Task UnsubscribeFromTopicAsync(string topicUniqueString) { diff --git a/ntfysh_client/SubscribeDialog.Designer.cs b/ntfysh_client/SubscribeDialog.Designer.cs index 30d5a60..4977b26 100644 --- a/ntfysh_client/SubscribeDialog.Designer.cs +++ b/ntfysh_client/SubscribeDialog.Designer.cs @@ -40,6 +40,8 @@ namespace ntfysh_client this.label3 = new System.Windows.Forms.Label(); this.password = new System.Windows.Forms.TextBox(); this.label4 = new System.Windows.Forms.Label(); + this.label5 = new System.Windows.Forms.Label(); + this.connectionType = new System.Windows.Forms.ComboBox(); this.panel1.SuspendLayout(); this.SuspendLayout(); // @@ -49,18 +51,19 @@ namespace ntfysh_client this.panel1.Controls.Add(this.button2); this.panel1.Controls.Add(this.button1); this.panel1.Dock = System.Windows.Forms.DockStyle.Bottom; - this.panel1.Location = new System.Drawing.Point(0, 175); + this.panel1.Location = new System.Drawing.Point(0, 244); + this.panel1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.panel1.Name = "panel1"; - this.panel1.Size = new System.Drawing.Size(297, 44); + this.panel1.Size = new System.Drawing.Size(346, 51); this.panel1.TabIndex = 0; // // button2 // this.button2.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right))); - this.button2.Location = new System.Drawing.Point(131, 11); - this.button2.Margin = new System.Windows.Forms.Padding(10, 10, 3, 10); + this.button2.Location = new System.Drawing.Point(153, 13); + this.button2.Margin = new System.Windows.Forms.Padding(12, 12, 4, 12); this.button2.Name = "button2"; - this.button2.Size = new System.Drawing.Size(75, 23); + this.button2.Size = new System.Drawing.Size(88, 27); this.button2.TabIndex = 1; this.button2.Text = "Cancel"; this.button2.UseVisualStyleBackColor = true; @@ -69,10 +72,10 @@ namespace ntfysh_client // button1 // this.button1.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right))); - this.button1.Location = new System.Drawing.Point(212, 11); - this.button1.Margin = new System.Windows.Forms.Padding(3, 10, 10, 10); + this.button1.Location = new System.Drawing.Point(247, 13); + this.button1.Margin = new System.Windows.Forms.Padding(4, 12, 12, 12); this.button1.Name = "button1"; - this.button1.Size = new System.Drawing.Size(75, 23); + this.button1.Size = new System.Drawing.Size(88, 27); this.button1.TabIndex = 2; this.button1.Text = "Subscribe"; this.button1.UseVisualStyleBackColor = true; @@ -81,9 +84,10 @@ namespace ntfysh_client // label1 // this.label1.AutoSize = true; - this.label1.Location = new System.Drawing.Point(12, 9); + this.label1.Location = new System.Drawing.Point(14, 10); + this.label1.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); this.label1.Name = "label1"; - this.label1.Size = new System.Drawing.Size(51, 13); + this.label1.Size = new System.Drawing.Size(52, 15); this.label1.TabIndex = 1; this.label1.Text = "Topic ID:"; // @@ -91,9 +95,10 @@ namespace ntfysh_client // this.topicId.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); - this.topicId.Location = new System.Drawing.Point(12, 25); + this.topicId.Location = new System.Drawing.Point(14, 29); + this.topicId.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.topicId.Name = "topicId"; - this.topicId.Size = new System.Drawing.Size(273, 20); + this.topicId.Size = new System.Drawing.Size(318, 23); this.topicId.TabIndex = 0; this.topicId.KeyDown += new System.Windows.Forms.KeyEventHandler(this.topicId_KeyDown); // @@ -101,19 +106,21 @@ namespace ntfysh_client // this.serverUrl.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); - this.serverUrl.Location = new System.Drawing.Point(12, 64); + this.serverUrl.Location = new System.Drawing.Point(14, 74); + this.serverUrl.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.serverUrl.Name = "serverUrl"; - this.serverUrl.Size = new System.Drawing.Size(273, 20); + this.serverUrl.Size = new System.Drawing.Size(318, 23); this.serverUrl.TabIndex = 2; - this.serverUrl.Text = "https://ntfy.sh"; + this.serverUrl.Text = "wss://ntfy.sh"; this.serverUrl.KeyDown += new System.Windows.Forms.KeyEventHandler(this.serverUrl_KeyDown); // // label2 // this.label2.AutoSize = true; - this.label2.Location = new System.Drawing.Point(10, 48); + this.label2.Location = new System.Drawing.Point(12, 55); + this.label2.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); this.label2.Name = "label2"; - this.label2.Size = new System.Drawing.Size(66, 13); + this.label2.Size = new System.Drawing.Size(66, 15); this.label2.TabIndex = 3; this.label2.Text = "Server URL:"; // @@ -121,18 +128,20 @@ namespace ntfysh_client // this.username.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); - this.username.Location = new System.Drawing.Point(12, 103); + this.username.Location = new System.Drawing.Point(14, 119); + this.username.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.username.Name = "username"; - this.username.Size = new System.Drawing.Size(273, 20); + this.username.Size = new System.Drawing.Size(318, 23); this.username.TabIndex = 4; this.username.KeyDown += new System.Windows.Forms.KeyEventHandler(this.username_KeyDown); // // label3 // this.label3.AutoSize = true; - this.label3.Location = new System.Drawing.Point(10, 87); + this.label3.Location = new System.Drawing.Point(12, 100); + this.label3.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); this.label3.Name = "label3"; - this.label3.Size = new System.Drawing.Size(58, 13); + this.label3.Size = new System.Drawing.Size(63, 15); this.label3.TabIndex = 5; this.label3.Text = "Username:"; // @@ -140,9 +149,10 @@ namespace ntfysh_client // this.password.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); - this.password.Location = new System.Drawing.Point(12, 142); + this.password.Location = new System.Drawing.Point(14, 164); + this.password.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.password.Name = "password"; - this.password.Size = new System.Drawing.Size(273, 20); + this.password.Size = new System.Drawing.Size(318, 23); this.password.TabIndex = 6; this.password.UseSystemPasswordChar = true; this.password.KeyDown += new System.Windows.Forms.KeyEventHandler(this.password_KeyDown); @@ -150,18 +160,44 @@ namespace ntfysh_client // label4 // this.label4.AutoSize = true; - this.label4.Location = new System.Drawing.Point(10, 126); + this.label4.Location = new System.Drawing.Point(12, 145); + this.label4.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); this.label4.Name = "label4"; - this.label4.Size = new System.Drawing.Size(56, 13); + this.label4.Size = new System.Drawing.Size(60, 15); this.label4.TabIndex = 7; this.label4.Text = "Password:"; // + // label5 + // + this.label5.AutoSize = true; + this.label5.Location = new System.Drawing.Point(12, 190); + this.label5.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + this.label5.Name = "label5"; + this.label5.Size = new System.Drawing.Size(99, 15); + this.label5.TabIndex = 9; + this.label5.Text = "Connection Type:"; + // + // connectionType + // + this.connectionType.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.connectionType.FormattingEnabled = true; + this.connectionType.Items.AddRange(new object[] { + "Websockets (Recommended)", + "Long HTTP JSON (Robust)"}); + this.connectionType.Location = new System.Drawing.Point(14, 208); + this.connectionType.Name = "connectionType"; + this.connectionType.Size = new System.Drawing.Size(318, 23); + this.connectionType.TabIndex = 10; + this.connectionType.TextChanged += new System.EventHandler(this.connectionType_TextChanged); + // // SubscribeDialog // - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.BackColor = System.Drawing.Color.White; - this.ClientSize = new System.Drawing.Size(297, 219); + this.ClientSize = new System.Drawing.Size(346, 295); + this.Controls.Add(this.connectionType); + this.Controls.Add(this.label5); this.Controls.Add(this.password); this.Controls.Add(this.label4); this.Controls.Add(this.username); @@ -172,6 +208,7 @@ namespace ntfysh_client this.Controls.Add(this.label1); this.Controls.Add(this.panel1); this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; + this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.MaximizeBox = false; this.MinimizeBox = false; this.Name = "SubscribeDialog"; @@ -179,6 +216,7 @@ namespace ntfysh_client this.ShowInTaskbar = false; this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; this.Text = "Subscribe to new topic"; + this.Load += new System.EventHandler(this.SubscribeDialog_Load); this.panel1.ResumeLayout(false); this.ResumeLayout(false); this.PerformLayout(); @@ -199,5 +237,7 @@ namespace ntfysh_client private System.Windows.Forms.Label label3; private System.Windows.Forms.TextBox password; private System.Windows.Forms.Label label4; + private System.Windows.Forms.Label label5; + private System.Windows.Forms.ComboBox connectionType; } } \ No newline at end of file diff --git a/ntfysh_client/SubscribeDialog.cs b/ntfysh_client/SubscribeDialog.cs index e5233f6..bc890b7 100644 --- a/ntfysh_client/SubscribeDialog.cs +++ b/ntfysh_client/SubscribeDialog.cs @@ -17,12 +17,107 @@ namespace ntfysh_client public string Unique => $"{topicId.Text}@{serverUrl.Text}"; + public bool UseWebsockets + { + get + { + switch (connectionType.Text) + { + case "Websockets (Recommended)": + return true; + + case "Long HTTP JSON (Robust)": + return false; + + default: + throw new InvalidOperationException(); + } + } + } + public SubscribeDialog(ListBox notificationTopics) { _notificationTopics = notificationTopics; InitializeComponent(); } + private void SubscribeDialog_Load(object sender, EventArgs e) + { + connectionType.SelectedIndex = 0; + } + + private bool ReparseAddress() + { + //Separate schema and address + string[] parts = serverUrl.Text.Split("://", 2); + + //Validate the basic formatting is correct + if (parts.Length != 2) return false; + + //Take the schema aside for parsing + string schema = parts[0].ToLower(); + + //Ensure the schema is actually valid + switch (schema) + { + case "http": + case "https": + case "ws": + case "wss": + break; + + default: + return false; + } + + //Correct the schema based on connection type if required + if (UseWebsockets) + { + switch (schema) + { + case "http": + schema = "ws"; + break; + + case "https": + schema = "wss"; + break; + + case "ws": + case "wss": + break; + } + } + else + { + switch (schema) + { + case "ws": + schema = "http"; + break; + + case "wss": + schema = "https"; + break; + + case "http": + case "https": + break; + } + } + + //Reconstruct the address + string finalAddress = schema + "://" + parts[1]; + + //Validate the address + if (!Uri.IsWellFormedUriString(finalAddress, UriKind.Absolute)) return false; + + //Set the final address and OK it + serverUrl.Text = finalAddress; + + return true; + } + private void button1_Click(object sender, EventArgs e) { if (topicId.Text.Length < 1) @@ -35,7 +130,7 @@ namespace ntfysh_client if (serverUrl.Text.Length < 1) { - MessageBox.Show("You must specify a server URL. The default is https://ntfy.sh", "Server URL not specified", MessageBoxButtons.OK, MessageBoxIcon.Error); + MessageBox.Show("You must specify a server URL. The default is wss://ntfy.sh", "Server URL not specified", MessageBoxButtons.OK, MessageBoxIcon.Error); DialogResult = DialogResult.None; serverUrl.Focus(); return; @@ -65,6 +160,26 @@ namespace ntfysh_client return; } + try + { + if (!ReparseAddress()) + { + MessageBox.Show($"The specified server URL is invalid. Accepted schemas are: http:// https:// ws:// wss://", "Invalid Server URL", MessageBoxButtons.OK, MessageBoxIcon.Error); + DialogResult = DialogResult.None; + connectionType.Focus(); + return; + } + } + catch (InvalidOperationException) + { + MessageBox.Show($"The selected Connection Type '{connectionType.Text}' is invalid.", "Invalid Connection Type", MessageBoxButtons.OK, MessageBoxIcon.Error); + DialogResult = DialogResult.None; + connectionType.Focus(); + return; + } + + + DialogResult = DialogResult.OK; } @@ -108,5 +223,10 @@ namespace ntfysh_client e.SuppressKeyPress = true; } } + + private void connectionType_TextChanged(object sender, EventArgs e) + { + ReparseAddress(); + } } } diff --git a/ntfysh_client/SubscribeDialog.resx b/ntfysh_client/SubscribeDialog.resx index 1af7de1..f298a7b 100644 --- a/ntfysh_client/SubscribeDialog.resx +++ b/ntfysh_client/SubscribeDialog.resx @@ -1,64 +1,4 @@ - - - +