diff --git a/.gitignore b/.gitignore index 103db3d..f08db5b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,399 @@ -bin/ -obj/ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory .vs/ -packages/ \ No newline at end of file +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +/.idea \ No newline at end of file diff --git a/ntfysh_client/Form1.cs b/ntfysh_client/Form1.cs deleted file mode 100644 index 2963b0b..0000000 --- a/ntfysh_client/Form1.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Data; -using System.Drawing; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Threading.Tasks; -using System.Windows.Forms; - -namespace ntfysh_client -{ - public partial class Form1 : Form - { - private NotificationListener notificationListener; - - public Form1() - { - notificationListener = new NotificationListener(); - notificationListener.OnNotificationReceive += OnNotificationReceive; - InitializeComponent(); - } - - private void Form1_Load(object sender, EventArgs e) - { - this.LoadTopics(); - } - - private void subscribeNewTopic_Click(object sender, EventArgs e) - { - using (var dialog = new SubscribeDialog()) - { - var result = dialog.ShowDialog(); - - if (result == DialogResult.OK) - { - notificationListener.SubscribeToTopic(dialog.getTopicId()); - notificationTopics.Items.Add(dialog.getTopicId()); - this.SaveTopicsToFile(); - } - } - } - - private void removeSelectedTopics_Click(object sender, EventArgs e) - { - while (notificationTopics.SelectedIndex > -1) - { - var topicId = notificationTopics.Items[notificationTopics.SelectedIndex]; - notificationListener.RemoveTopic((string)topicId); - notificationTopics.Items.RemoveAt(notificationTopics.SelectedIndex); - } - - this.SaveTopicsToFile(); - } - - private void notificationTopics_SelectedValueChanged(object sender, EventArgs e) - { - removeSelectedTopics.Enabled = notificationTopics.SelectedIndices.Count > 0; - } - - private void notificationTopics_Click(object sender, EventArgs e) - { - var ev = (MouseEventArgs)e; - var clickedItemIndex = notificationTopics.IndexFromPoint(new Point(ev.X, ev.Y)); - - if (clickedItemIndex == -1) - { - notificationTopics.ClearSelected(); - } - } - - private void button1_Click(object sender, EventArgs e) - { - this.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(); - } - } - - private void showControlWindowToolStripMenuItem_Click(object sender, EventArgs e) - { - this.Visible = true; - this.BringToFront(); - } - - private string GetTopicsFilePath() - { - string binaryDirectory = System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - return Path.Combine(binaryDirectory, "topics.txt"); - } - - private void SaveTopicsToFile() - { - using (StreamWriter writer = new StreamWriter(GetTopicsFilePath())) - { - foreach (string topic in notificationTopics.Items) - { - writer.WriteLine(topic); - } - } - } - - private void LoadTopics() - { - if (!File.Exists(GetTopicsFilePath())) return; - using (StreamReader reader = new StreamReader(GetTopicsFilePath())) - { - while (!reader.EndOfStream) - { - var topic = reader.ReadLine(); - notificationListener.SubscribeToTopic(topic); - notificationTopics.Items.Add(topic); - } - } - } - - private void OnNotificationReceive(object sender, NotificationReceiveEventArgs e) - { - notifyIcon.ShowBalloonTip(3000, e.Title, e.Message, ToolTipIcon.Info); - } - - private void Form1_FormClosed(object sender, FormClosedEventArgs e) - { - notifyIcon.Dispose(); - } - - private bool trueExit = false; - private void Form1_FormClosing(object sender, FormClosingEventArgs e) - { - // Let it close - if (trueExit) return; - - if (e.CloseReason == CloseReason.UserClosing) - { - this.Visible = false; - e.Cancel = true; - } - } - - private void exitToolStripMenuItem_Click(object sender, EventArgs e) - { - trueExit = true; - this.Close(); - } - - private void ntfyshWebsiteToolStripMenuItem_Click(object sender, EventArgs e) - { - System.Diagnostics.Process.Start("https://ntfy.sh/"); - } - - private void aboutToolStripMenuItem_Click(object sender, EventArgs e) - { - var d = new AboutBox(); - d.ShowDialog(); - d.Dispose(); - } - } -} diff --git a/ntfysh_client/Form1.Designer.cs b/ntfysh_client/MainForm.Designer.cs similarity index 90% rename from ntfysh_client/Form1.Designer.cs rename to ntfysh_client/MainForm.Designer.cs index e9b6d1e..83e8285 100644 --- a/ntfysh_client/Form1.Designer.cs +++ b/ntfysh_client/MainForm.Designer.cs @@ -1,7 +1,7 @@  namespace ntfysh_client { - partial class Form1 + partial class MainForm { /// /// Required designer variable. @@ -30,7 +30,7 @@ namespace ntfysh_client private void InitializeComponent() { this.components = new System.ComponentModel.Container(); - System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(Form1)); + System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(MainForm)); this.subscribeNewTopic = new System.Windows.Forms.Button(); this.removeSelectedTopics = new System.Windows.Forms.Button(); this.notificationTopics = new System.Windows.Forms.ListBox(); @@ -40,12 +40,12 @@ namespace ntfysh_client this.exitToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.menuStrip1 = new System.Windows.Forms.MenuStrip(); this.fileToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.helpToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.label1 = new System.Windows.Forms.Label(); this.exitToolStripMenuItem1 = new System.Windows.Forms.ToolStripMenuItem(); - this.aboutToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.helpToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.ntfyshWebsiteToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.toolStripMenuItem1 = new System.Windows.Forms.ToolStripSeparator(); + this.aboutToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.label1 = new System.Windows.Forms.Label(); this.trayContextMenu.SuspendLayout(); this.menuStrip1.SuspendLayout(); this.SuspendLayout(); @@ -53,9 +53,10 @@ namespace ntfysh_client // subscribeNewTopic // this.subscribeNewTopic.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right))); - this.subscribeNewTopic.Location = new System.Drawing.Point(236, 50); + this.subscribeNewTopic.Location = new System.Drawing.Point(211, 251); + this.subscribeNewTopic.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.subscribeNewTopic.Name = "subscribeNewTopic"; - this.subscribeNewTopic.Size = new System.Drawing.Size(105, 23); + this.subscribeNewTopic.Size = new System.Drawing.Size(188, 27); this.subscribeNewTopic.TabIndex = 2; this.subscribeNewTopic.Text = "Add"; this.subscribeNewTopic.UseVisualStyleBackColor = true; @@ -65,9 +66,10 @@ namespace ntfysh_client // this.removeSelectedTopics.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right))); this.removeSelectedTopics.Enabled = false; - this.removeSelectedTopics.Location = new System.Drawing.Point(236, 79); + this.removeSelectedTopics.Location = new System.Drawing.Point(13, 251); + this.removeSelectedTopics.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.removeSelectedTopics.Name = "removeSelectedTopics"; - this.removeSelectedTopics.Size = new System.Drawing.Size(105, 23); + this.removeSelectedTopics.Size = new System.Drawing.Size(188, 27); this.removeSelectedTopics.TabIndex = 0; this.removeSelectedTopics.Text = "Remove selected"; this.removeSelectedTopics.UseVisualStyleBackColor = true; @@ -78,10 +80,12 @@ namespace ntfysh_client this.notificationTopics.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); this.notificationTopics.FormattingEnabled = true; - this.notificationTopics.Location = new System.Drawing.Point(12, 50); + this.notificationTopics.ItemHeight = 15; + this.notificationTopics.Location = new System.Drawing.Point(13, 46); + this.notificationTopics.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.notificationTopics.Name = "notificationTopics"; this.notificationTopics.SelectionMode = System.Windows.Forms.SelectionMode.MultiExtended; - this.notificationTopics.Size = new System.Drawing.Size(145, 173); + this.notificationTopics.Size = new System.Drawing.Size(386, 199); this.notificationTopics.TabIndex = 3; this.notificationTopics.Click += new System.EventHandler(this.notificationTopics_Click); this.notificationTopics.SelectedValueChanged += new System.EventHandler(this.notificationTopics_SelectedValueChanged); @@ -126,7 +130,8 @@ namespace ntfysh_client this.helpToolStripMenuItem}); this.menuStrip1.Location = new System.Drawing.Point(0, 0); this.menuStrip1.Name = "menuStrip1"; - this.menuStrip1.Size = new System.Drawing.Size(353, 24); + this.menuStrip1.Padding = new System.Windows.Forms.Padding(7, 2, 0, 2); + this.menuStrip1.Size = new System.Drawing.Size(412, 24); this.menuStrip1.TabIndex = 4; this.menuStrip1.Text = "menuStrip1"; // @@ -138,6 +143,14 @@ namespace ntfysh_client this.fileToolStripMenuItem.Size = new System.Drawing.Size(37, 20); this.fileToolStripMenuItem.Text = "File"; // + // exitToolStripMenuItem1 + // + this.exitToolStripMenuItem1.Image = ((System.Drawing.Image)(resources.GetObject("exitToolStripMenuItem1.Image"))); + this.exitToolStripMenuItem1.Name = "exitToolStripMenuItem1"; + this.exitToolStripMenuItem1.Size = new System.Drawing.Size(93, 22); + this.exitToolStripMenuItem1.Text = "Exit"; + this.exitToolStripMenuItem1.Click += new System.EventHandler(this.exitToolStripMenuItem1_Click); + // // helpToolStripMenuItem // this.helpToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { @@ -148,30 +161,6 @@ namespace ntfysh_client this.helpToolStripMenuItem.Size = new System.Drawing.Size(44, 20); this.helpToolStripMenuItem.Text = "Help"; // - // label1 - // - this.label1.AutoSize = true; - this.label1.Location = new System.Drawing.Point(12, 34); - this.label1.Name = "label1"; - this.label1.Size = new System.Drawing.Size(145, 13); - this.label1.TabIndex = 1; - this.label1.Text = "Subscribed notification topics"; - // - // exitToolStripMenuItem1 - // - this.exitToolStripMenuItem1.Image = ((System.Drawing.Image)(resources.GetObject("exitToolStripMenuItem1.Image"))); - this.exitToolStripMenuItem1.Name = "exitToolStripMenuItem1"; - this.exitToolStripMenuItem1.Size = new System.Drawing.Size(180, 22); - this.exitToolStripMenuItem1.Text = "Exit"; - // - // aboutToolStripMenuItem - // - this.aboutToolStripMenuItem.Image = ((System.Drawing.Image)(resources.GetObject("aboutToolStripMenuItem.Image"))); - this.aboutToolStripMenuItem.Name = "aboutToolStripMenuItem"; - this.aboutToolStripMenuItem.Size = new System.Drawing.Size(185, 22); - this.aboutToolStripMenuItem.Text = "About"; - this.aboutToolStripMenuItem.Click += new System.EventHandler(this.aboutToolStripMenuItem_Click); - // // ntfyshWebsiteToolStripMenuItem // this.ntfyshWebsiteToolStripMenuItem.Image = ((System.Drawing.Image)(resources.GetObject("ntfyshWebsiteToolStripMenuItem.Image"))); @@ -185,12 +174,30 @@ namespace ntfysh_client this.toolStripMenuItem1.Name = "toolStripMenuItem1"; this.toolStripMenuItem1.Size = new System.Drawing.Size(182, 6); // - // Form1 + // aboutToolStripMenuItem // - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.aboutToolStripMenuItem.Image = ((System.Drawing.Image)(resources.GetObject("aboutToolStripMenuItem.Image"))); + this.aboutToolStripMenuItem.Name = "aboutToolStripMenuItem"; + this.aboutToolStripMenuItem.Size = new System.Drawing.Size(185, 22); + this.aboutToolStripMenuItem.Text = "About"; + this.aboutToolStripMenuItem.Click += new System.EventHandler(this.aboutToolStripMenuItem_Click); + // + // label1 + // + this.label1.AutoSize = true; + this.label1.Location = new System.Drawing.Point(13, 27); + this.label1.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(170, 15); + this.label1.TabIndex = 1; + this.label1.Text = "Subscribed Notification Topics:"; + // + // MainForm + // + 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(353, 236); + this.ClientSize = new System.Drawing.Size(412, 288); this.Controls.Add(this.menuStrip1); this.Controls.Add(this.notificationTopics); this.Controls.Add(this.removeSelectedTopics); @@ -200,14 +207,15 @@ namespace ntfysh_client this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon"))); this.KeyPreview = true; this.MainMenuStrip = this.menuStrip1; + this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.MaximizeBox = false; this.MinimizeBox = false; - this.Name = "Form1"; + this.Name = "MainForm"; this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; this.Text = "ntfy.sh"; - this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.Form1_FormClosing); - this.FormClosed += new System.Windows.Forms.FormClosedEventHandler(this.Form1_FormClosed); - this.Load += new System.EventHandler(this.Form1_Load); + this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.MainForm_FormClosing); + this.FormClosed += new System.Windows.Forms.FormClosedEventHandler(this.MainForm_FormClosed); + this.Load += new System.EventHandler(this.MainForm_Load); this.trayContextMenu.ResumeLayout(false); this.menuStrip1.ResumeLayout(false); this.menuStrip1.PerformLayout(); diff --git a/ntfysh_client/MainForm.cs b/ntfysh_client/MainForm.cs new file mode 100644 index 0000000..bd1bf9f --- /dev/null +++ b/ntfysh_client/MainForm.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Windows.Forms; +using Newtonsoft.Json; + +namespace ntfysh_client +{ + public partial class MainForm : Form + { + private readonly NotificationListener _notificationListener; + private bool _trueExit; + + public MainForm(NotificationListener notificationListener) + { + _notificationListener = notificationListener; + _notificationListener.OnNotificationReceive += OnNotificationReceive; + _notificationListener.OnConnectionMultiAttemptFailure += OnConnectionMultiAttemptFailure; + _notificationListener.OnConnectionCredentialsFailure += OnConnectionCredentialsFailure; + + 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 subscribeNewTopic_Click(object sender, EventArgs e) + { + using SubscribeDialog dialog = new SubscribeDialog(notificationTopics); + DialogResult result = dialog.ShowDialog(); + + //Do not subscribe on cancelled dialog + if (result != DialogResult.OK) return; + + //Subscribe + 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); + + //Save the topics persistently + SaveTopicsToFile(); + } + + private async void removeSelectedTopics_Click(object sender, EventArgs e) + { + while (notificationTopics.SelectedIndex > -1) + { + string topicUniqueString = (string)notificationTopics.Items[notificationTopics.SelectedIndex]; + + await _notificationListener.UnsubscribeFromTopicAsync(topicUniqueString); + notificationTopics.Items.Remove(topicUniqueString); + } + + SaveTopicsToFile(); + } + + private void notificationTopics_SelectedValueChanged(object sender, EventArgs e) + { + removeSelectedTopics.Enabled = notificationTopics.SelectedIndices.Count > 0; + } + + private void notificationTopics_Click(object sender, EventArgs e) + { + MouseEventArgs ev = (MouseEventArgs)e; + int clickedItemIndex = notificationTopics.IndexFromPoint(new Point(ev.X, ev.Y)); + + if (clickedItemIndex == -1) notificationTopics.ClearSelected(); + } + + private void button1_Click(object sender, EventArgs e) + { + Visible = false; + } + + private void notifyIcon_Click(object sender, EventArgs e) + { + MouseEventArgs mouseEv = (MouseEventArgs)e; + + if (mouseEv.Button != MouseButtons.Left) return; + + Visible = !Visible; + BringToFront(); + } + + private void showControlWindowToolStripMenuItem_Click(object sender, EventArgs e) + { + Visible = true; + BringToFront(); + } + + private string GetTopicsFilePath() + { + 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) ?? 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); + + File.WriteAllText(GetTopicsFilePath(), topicsSerialised); + } + + private void LoadTopics() + { + string legacyTopicsPath = GetLegacyTopicsFilePath(); + string topicsFilePath = GetTopicsFilePath(); + + //If we have an old format topics file. Convert it to the new format! + if (File.Exists(legacyTopicsPath)) + { + //Read old format + List legacyTopics = new List(); + + using (StreamReader reader = new StreamReader(legacyTopicsPath)) + { + while (!reader.EndOfStream) + { + 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)).ToList(); + + string newFormatSerialised = JsonConvert.SerializeObject(newTopics, Formatting.Indented); + + //Write new format + File.WriteAllText(topicsFilePath, newFormatSerialised); + + //Delete old format + File.Delete(legacyTopicsPath); + } + + //Check if we have any topics file on disk to load + if (!File.Exists(topicsFilePath)) return; + + //We have a topics file. Load it! + string topicsSerialised = File.ReadAllText(topicsFilePath); + + //Check if the file is empty + if (string.IsNullOrWhiteSpace(topicsSerialised)) + { + //The file is empty. May as well remove it and consider it nonexistent + File.Delete(topicsFilePath); + return; + } + + //Deserialise the topics + List? topics = JsonConvert.DeserializeObject>(topicsSerialised); + + if (topics is null) + { + //TODO Deserialise error! + return; + } + + //Load them in + foreach (SubscribedTopic topic in topics) + { + 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}"); + } + } + + private void MainForm_FormClosed(object sender, FormClosedEventArgs e) + { + notifyIcon.Dispose(); + } + + private void MainForm_FormClosing(object sender, FormClosingEventArgs e) + { + // Let it close + if (_trueExit) return; + + if (e.CloseReason != CloseReason.UserClosing) return; + + Visible = false; + e.Cancel = true; + } + + private void exitToolStripMenuItem_Click(object sender, EventArgs e) + { + _trueExit = true; + Close(); + } + + private void ntfyshWebsiteToolStripMenuItem_Click(object sender, EventArgs e) + { + Process.Start(new ProcessStartInfo("https://ntfy.sh/") + { + UseShellExecute = true + }); + } + + private void aboutToolStripMenuItem_Click(object sender, EventArgs e) + { + using AboutBox d = new AboutBox(); + d.ShowDialog(); + } + + private void exitToolStripMenuItem1_Click(object sender, EventArgs e) + { + _trueExit = true; + Close(); + } + } +} diff --git a/ntfysh_client/Form1.resx b/ntfysh_client/MainForm.resx similarity index 92% rename from ntfysh_client/Form1.resx rename to ntfysh_client/MainForm.resx index 4b4cee7..dbf516e 100644 --- a/ntfysh_client/Form1.resx +++ b/ntfysh_client/MainForm.resx @@ -1,64 +1,4 @@ - - - + @@ -124,6 +64,24 @@ 123, 17 + + + iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6 + JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAAA6+AAAOvgHqQrHAAAAAiklE + QVQ4T8WPQQqAIBBFvUOrXIhu3dcZOkqtu5R1ia4SdRDrxyyGGKmBIOGBDL6nmk9WCGE9yUpW0q9Ads7V + GuCQ/kHAe79joAEO6dcLFmttpQEO6c+Bvpm2oZ0zwB4zVQBiF8cIsMdMDPCb+G2vA/wgP/z6C6WAhBgo + fUFCDGi4BxIGShLpvy5jDoPes/0oNG3VAAAAAElFTkSuQmCC + + + + + iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6 + JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAAA6+AAAOvgHqQrHAAAAAgUlE + QVQ4T2OgGjjOLq4AZeIFWNWBBI9yib06zC3uABXCCkDyIHVYDTnMI2pzhEvs5VFucSeoEAo4wiNii08e + DHAZQshwFICumCTNMADyK1gTl2gJiCYUNlgBSDPQ1v8gGipEPKDIBRSFAa6oIsoQQvGM1xCqpESsglgA + seroBRgYAOoOWBJbfVcRAAAAAElFTkSuQmCC + + AAABAAEAMjIAAAEAIADIKAAAFgAAACgAAAAyAAAAZAAAAAEAIAAAAAAAECcAACMuAAAjLgAAAAAAAAAA @@ -303,31 +261,22 @@ /////8AA////////wAD////////AAA== - + + 269, 17 + + iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6 - JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAiklE - QVQ4T8WPQQqAIBBFvUOrXIhu3dcZOkqtu5R1ia4SdRDrxyyGGKmBIOGBDL6nmk9WCGE9yUpW0q9Ads7V - GuCQ/kHAe79joAEO6dcLFmttpQEO6c+Bvpm2oZ0zwB4zVQBiF8cIsMdMDPCb+G2vA/wgP/z6C6WAhBgo - fUFCDGi4BxIGShLpvy5jDoPes/0oNG3VAAAAAElFTkSuQmCC - - - - - iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6 - JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAgUlE + JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAAA6+AAAOvgHqQrHAAAAAgUlE QVQ4T2OgGjjOLq4AZeIFWNWBBI9yib06zC3uABXCCkDyIHVYDTnMI2pzhEvs5VFucSeoEAo4wiNii08e DHAZQshwFICumCTNMADyK1gTl2gJiCYUNlgBSDPQ1v8gGipEPKDIBRSFAa6oIsoQQvGM1xCqpESsglgA seroBRgYAOoOWBJbfVcRAAAAAElFTkSuQmCC - - 269, 17 - iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6 - JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAAA7DAAAOwwHHb6hkAAABDElE + JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAAA6+AAAOvgHqQrHAAAABDElE QVQ4T91RO3bCQAzUy3uhChdJnTVlCj5FcMsZsF1jmy4XycPrzl6fBLgEXMFp4srRiF0wcaBP5j0VI2m0 4zH9EwSq8gPP7ELPNFxtpxrbn9vVPkSsykOkqvHi2QxsWwAevhQTzG8eCb1yD7Glv2I5MlM+sLX0GqEq 6+jVPFl6QZz7lOgdJVnzuMq/sEepbsGln2YnR3AAm0IcIE71geJsTO9mcOWAOa3zicxxxGWApXMGabaH @@ -338,20 +287,11 @@ iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6 - JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAwUlE + JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAAA6+AAAOvgHqQrHAAAAAwUlE QVQ4T2OgClBUVHwAxP9JxA+g2sEG/JeTk5MkBYP0QLVTwQAFBYXHIAFSMEgPVDvYBfulpKSEScEgPVDt dDIA6OTNQHX/YXysBjAxMcUxMjKuBrGB9DIgPxqmARhwOUBD3sD4WA0QERGRBHLvCwoKygLpm6KiohIw DVBNz5HY2L0AtHkOEE8F4okgvoyMjDSSJsIGsLOzuwKFfnBycppJS0vLAJ39BUQTbQA6RnYBMkY3YAVI gES8Aqp9QAEDAwCq9oYvtggceQAAAABJRU5ErkJggg== - - - - - iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6 - JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAgUlE - QVQ4T2OgGjjOLq4AZeIFWNWBBI9yib06zC3uABXCCkDyIHVYDTnMI2pzhEvs5VFucSeoEAo4wiNii08e - DHAZQshwFICumCTNMADyK1gTl2gJiCYUNlgBSDPQ1v8gGipEPKDIBRSFAa6oIsoQQvGM1xCqpESsglgA - seroBRgYAOoOWBJbfVcRAAAAAElFTkSuQmCC diff --git a/ntfysh_client/NotificationListener.cs b/ntfysh_client/NotificationListener.cs index 75f6118..5e4d58c 100644 --- a/ntfysh_client/NotificationListener.cs +++ b/ntfysh_client/NotificationListener.cs @@ -6,6 +6,8 @@ using System.IO; 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; @@ -13,142 +15,334 @@ using System.Web; namespace ntfysh_client { - class NotificationListener : IDisposable + public class NotificationListener { - private HttpClient httpClient; + public readonly Dictionary SubscribedTopicsByUnique = new(); + + public delegate void NotificationReceiveHandler(NotificationListener sender, NotificationReceiveEventArgs e); + public event NotificationReceiveHandler? OnNotificationReceive; - private bool disposedValue; - - public Dictionary subscribedTopics; - - public delegate void NotificationReceiveHandler(object sender, NotificationReceiveEventArgs e); - public event NotificationReceiveHandler OnNotificationReceive; + public delegate void ConnectionErrorHandler(NotificationListener sender, SubscribedTopic topic); + public event ConnectionErrorHandler? OnConnectionMultiAttemptFailure; + public event ConnectionErrorHandler? OnConnectionCredentialsFailure; public NotificationListener() { - httpClient = new HttpClient(); - subscribedTopics = new Dictionary(); - - httpClient.Timeout = TimeSpan.FromMilliseconds(Timeout.Infinite); ServicePointManager.DefaultConnectionLimit = 100; } - public async Task SubscribeToTopic(string topicId) + private async Task ListenToTopicWithHttpLongJsonAsync(HttpRequestMessage message, CancellationToken cancellationToken, SubscribedTopic topic) { - HttpRequestMessage msg = new HttpRequestMessage(HttpMethod.Get, $"https://ntfy.sh/{HttpUtility.UrlEncode(topicId)}/json"); - using (var response = await httpClient.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead)) + int connectionAttempts = 0; + + while (!cancellationToken.IsCancellationRequested) { - using (var body = await response.Content.ReadAsStreamAsync()) + //See if we have exceeded maximum attempts + if (connectionAttempts >= 10) { - using (StreamReader reader = new StreamReader(body)) + //10 connection failures (1 initial + 9 reattempts)! Do not retry + OnConnectionMultiAttemptFailure?.Invoke(this, topic); + return; + } + + 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(); + + while (!cancellationToken.IsCancellationRequested) { - subscribedTopics.Add(topicId, reader); + //Read as much as possible + byte[] buffer = new byte[8192]; + int readBytes = await body.ReadAsync(buffer, 0, buffer.Length, cancellationToken); - try + //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 (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 + Debug.WriteLine(hre); + #endif + + //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) { - // 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); - } - } - } + //On our first reconnect attempt, try instantly. On consecutive, wait 3 seconds before each attempt + await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); } - catch (Exception ex) + + //Increment attempts + connectionAttempts++; + + //Proceed to reattempt + } + } + } + } + + private async Task ListenToTopicWithWebsocketAsync(Uri uri, string? credentials, CancellationToken cancellationToken, SubscribedTopic topic) + { + int connectionAttempts = 0; + + while (!cancellationToken.IsCancellationRequested) + { + //See if we have exceeded maximum attempts + if (connectionAttempts >= 10) + { + //10 connection failures (1 initial + 9 reattempts)! Do not retry + OnConnectionMultiAttemptFailure?.Invoke(this, topic); + return; + } + + try + { + //Establish connection + using ClientWebSocket socket = new(); + + if (!string.IsNullOrWhiteSpace(credentials)) socket.Options.SetRequestHeader("Authorization", "Basic " + credentials); + + await socket.ConnectAsync(uri, cancellationToken); + + //Reset connection attempts after a successful connect + connectionAttempts = 0; + + //Begin listening + StringBuilder mainBuffer = new(); + + 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 (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 + Debug.WriteLine(wse); + #endif + + //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) { - Debug.WriteLine(ex); - - // If the topic is still registered, then that stream wasn't mean to be closed (maybe network failure?) - // Restart it - if (subscribedTopics.ContainsKey(topicId)) - { - SubscribeToTopic(topicId); - } + //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 } } } } - public void RemoveTopic(string topicId) + private void ProcessMessage(string message) { - Debug.WriteLine($"Removing topic {topicId}"); + #if DEBUG + Debug.WriteLine(message); + #endif - if (subscribedTopics.ContainsKey(topicId)) + NtfyEvent? evt = JsonConvert.DeserializeObject(message); + + //If we hit this, ntfy sent us an invalid message + if (evt is null) return; + + if (evt.Event == "message") { - // Not moronic to store it in a variable; this solves a race condition in SubscribeToTopic - var topic = subscribedTopics[topicId]; - subscribedTopics.Remove(topicId); - topic.Close(); + OnNotificationReceive?.Invoke(this, new NotificationReceiveEventArgs(evt.Title, evt.Message)); } } - protected virtual void Dispose(bool disposing) + public void SubscribeToTopicUsingLongHttpJson(string unique, string topicId, string serverUrl, string? username, string? password) { - if (!disposedValue) + 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 message = new HttpRequestMessage(HttpMethod.Get, $"{serverUrl}/{HttpUtility.UrlEncode(topicId)}/json"); + + if (username is not null && password is not null) { - if (disposing) - { - // TODO: dispose managed state (managed objects) - } + byte[] boundCredentialsBytes = Encoding.UTF8.GetBytes($"{username}:{password}"); - // TODO: free unmanaged resources (unmanaged objects) and override finalizer - // TODO: set large fields to null - disposedValue = true; + message.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(boundCredentialsBytes)); } + + SubscribedTopic newTopic = new(topicId, serverUrl, username, password); + + CancellationTokenSource listenCanceller = new(); + Task listenTask = ListenToTopicWithHttpLongJsonAsync(message, listenCanceller.Token, newTopic); + + newTopic.SetAssociatedRunner(listenTask, listenCanceller); + + SubscribedTopicsByUnique.Add(unique, newTopic); } - - // // 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 SubscribeToTopicUsingWebsocket(string unique, string topicId, string serverUrl, string? username, string? password) { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose(disposing: true); - GC.SuppressFinalize(this); + 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; + + SubscribedTopic newTopic = new(topicId, serverUrl, username, password); + + string? credentials = null; + + if (username is not null && password is not null) + { + byte[] boundCredentialsBytes = Encoding.UTF8.GetBytes($"{username}:{password}"); + + credentials = Convert.ToBase64String(boundCredentialsBytes); + } + + CancellationTokenSource listenCanceller = new(); + Task listenTask = ListenToTopicWithWebsocketAsync(new Uri($"{serverUrl}/{HttpUtility.UrlEncode(topicId)}/ws"), credentials, listenCanceller.Token, newTopic); + + newTopic.SetAssociatedRunner(listenTask, listenCanceller); + + SubscribedTopicsByUnique.Add(unique, newTopic); } - } - public class NotificationReceiveEventArgs : EventArgs - { - public string Title { get; private set; } - public string Message { get; private set; } - - public NotificationReceiveEventArgs(string title, string message) + public async Task UnsubscribeFromTopicAsync(string topicUniqueString) { - Title = title; - Message = message; - } - } + #if DEBUG + Debug.WriteLine($"Removing topic {topicUniqueString}"); + #endif + + // ReSharper disable once InlineOutVariableDeclaration - Needed to avoid nullable warning + SubscribedTopic topic; - 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; } + //Topic isn't even subscribed, ignore + if (!SubscribedTopicsByUnique.TryGetValue(topicUniqueString, out topic!)) return; + + //Cancel and dispose the task runner + topic.RunnerCanceller?.Cancel(); + + //Wait for the task runner to shut down + try + { + if (topic.Runner is not null) await topic.Runner; + } + catch (Exception) + { + // ignored + } + + //Dispose task + topic.Runner?.Dispose(); + + //Remove the old topic + SubscribedTopicsByUnique.Remove(topicUniqueString); + } } } 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..5841c89 100644 --- a/ntfysh_client/Program.cs +++ b/ntfysh_client/Program.cs @@ -8,15 +8,18 @@ namespace ntfysh_client { static class Program { + private static readonly NotificationListener NotificationListener = new NotificationListener(); + /// /// The main entry point for the application. /// [STAThread] static void Main() { + Application.SetHighDpiMode(HighDpiMode.SystemAware); Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); - Application.Run(new Form1()); + Application.Run(new MainForm(NotificationListener)); } } } diff --git a/ntfysh_client/Properties/AssemblyInfo.cs b/ntfysh_client/Properties/AssemblyInfo.cs deleted file mode 100644 index 07fb348..0000000 --- a/ntfysh_client/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("ntfy.sh")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("ntfy.sh")] -[assembly: AssemblyCopyright("Copyright © 2022")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("5a18d152-d620-43fe-b844-def30cfa50ef")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/ntfysh_client/Properties/Resources.Designer.cs b/ntfysh_client/Properties/Resources.Designer.cs deleted file mode 100644 index 8cf2c22..0000000 --- a/ntfysh_client/Properties/Resources.Designer.cs +++ /dev/null @@ -1,63 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace ntfysh_client.Properties { - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class Resources { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Resources() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("ntfysh_client.Properties.Resources", typeof(Resources).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - } -} diff --git a/ntfysh_client/Properties/Resources.resx b/ntfysh_client/Properties/Resources.resx deleted file mode 100644 index af7dbeb..0000000 --- a/ntfysh_client/Properties/Resources.resx +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - \ No newline at end of file diff --git a/ntfysh_client/Properties/Settings.Designer.cs b/ntfysh_client/Properties/Settings.Designer.cs deleted file mode 100644 index 67b3c60..0000000 --- a/ntfysh_client/Properties/Settings.Designer.cs +++ /dev/null @@ -1,26 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace ntfysh_client.Properties { - - - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "16.10.0.0")] - internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { - - private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); - - public static Settings Default { - get { - return defaultInstance; - } - } - } -} diff --git a/ntfysh_client/Properties/Settings.settings b/ntfysh_client/Properties/Settings.settings deleted file mode 100644 index 3964565..0000000 --- a/ntfysh_client/Properties/Settings.settings +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/ntfysh_client/SubscribeDialog.Designer.cs b/ntfysh_client/SubscribeDialog.Designer.cs index e7d369e..9f1e66c 100644 --- a/ntfysh_client/SubscribeDialog.Designer.cs +++ b/ntfysh_client/SubscribeDialog.Designer.cs @@ -30,10 +30,18 @@ namespace ntfysh_client private void InitializeComponent() { this.panel1 = new System.Windows.Forms.Panel(); - this.button1 = new System.Windows.Forms.Button(); this.button2 = new System.Windows.Forms.Button(); + this.button1 = new System.Windows.Forms.Button(); this.label1 = new System.Windows.Forms.Label(); this.topicId = new System.Windows.Forms.TextBox(); + this.serverUrl = new System.Windows.Forms.TextBox(); + this.label2 = new System.Windows.Forms.Label(); + this.username = new System.Windows.Forms.TextBox(); + 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(); // @@ -43,64 +51,164 @@ 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, 81); + 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.TabIndex = 0; - // - // 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.Name = "button1"; - this.button1.Size = new System.Drawing.Size(75, 23); - this.button1.TabIndex = 2; - this.button1.Text = "Subscribe"; - this.button1.UseVisualStyleBackColor = true; - this.button1.Click += new System.EventHandler(this.button1_Click); + this.panel1.Size = new System.Drawing.Size(346, 51); + this.panel1.TabIndex = 8; // // 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.TabIndex = 1; + this.button2.Size = new System.Drawing.Size(88, 27); + this.button2.TabIndex = 7; this.button2.Text = "Cancel"; this.button2.UseVisualStyleBackColor = true; this.button2.Click += new System.EventHandler(this.button2_Click); // + // 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(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(88, 27); + this.button1.TabIndex = 6; + this.button1.Text = "Subscribe"; + this.button1.UseVisualStyleBackColor = true; + this.button1.Click += new System.EventHandler(this.button1_Click); + // // label1 // this.label1.AutoSize = true; - this.label1.Location = new System.Drawing.Point(12, 23); + 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:"; // // topicId // - this.topicId.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + 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(15, 39); + 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.TabIndex = 0; + this.topicId.Size = new System.Drawing.Size(318, 23); + this.topicId.TabIndex = 1; this.topicId.KeyDown += new System.Windows.Forms.KeyEventHandler(this.topicId_KeyDown); // + // serverUrl + // + 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(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(318, 23); + this.serverUrl.TabIndex = 2; + 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(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, 15); + this.label2.TabIndex = 3; + this.label2.Text = "Server URL:"; + // + // username + // + 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(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(318, 23); + this.username.TabIndex = 3; + this.username.KeyDown += new System.Windows.Forms.KeyEventHandler(this.username_KeyDown); + // + // label3 + // + this.label3.AutoSize = true; + 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(63, 15); + this.label3.TabIndex = 5; + this.label3.Text = "Username:"; + // + // password + // + 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(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(318, 23); + this.password.TabIndex = 4; + this.password.UseSystemPasswordChar = true; + this.password.KeyDown += new System.Windows.Forms.KeyEventHandler(this.password_KeyDown); + // + // label4 + // + this.label4.AutoSize = true; + 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(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 = 5; + 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, 125); + 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); + this.Controls.Add(this.label3); + this.Controls.Add(this.serverUrl); + this.Controls.Add(this.label2); this.Controls.Add(this.topicId); 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"; @@ -108,12 +216,16 @@ 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(); } + private System.Windows.Forms.TextBox serverUrl; + private System.Windows.Forms.Label label2; + #endregion private System.Windows.Forms.Panel panel1; @@ -121,5 +233,11 @@ namespace ntfysh_client private System.Windows.Forms.Button button1; private System.Windows.Forms.Label label1; private System.Windows.Forms.TextBox topicId; + private System.Windows.Forms.TextBox username; + 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 7e4ded3..bc890b7 100644 --- a/ntfysh_client/SubscribeDialog.cs +++ b/ntfysh_client/SubscribeDialog.cs @@ -1,25 +1,121 @@ using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Data; -using System.Drawing; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using System.Windows.Forms; namespace ntfysh_client { public partial class SubscribeDialog : Form { - public SubscribeDialog() + 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 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(); } - public string getTopicId() + private void SubscribeDialog_Load(object sender, EventArgs e) { - return topicId.Text; + 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) @@ -32,6 +128,58 @@ namespace ntfysh_client return; } + if (serverUrl.Text.Length < 1) + { + 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; + } + + if (username.Text.Length > 0 && password.Text.Length < 1) + { + MessageBox.Show("You must specify a password alongside the username", "Password not specified", MessageBoxButtons.OK, MessageBoxIcon.Error); + DialogResult = DialogResult.None; + password.Focus(); + return; + } + + if (password.Text.Length > 0 && username.Text.Length < 1) + { + MessageBox.Show("You must specify a username alongside the password", "Username not specified", MessageBoxButtons.OK, MessageBoxIcon.Error); + DialogResult = DialogResult.None; + username.Focus(); + return; + } + + 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; + username.Focus(); + 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; } @@ -48,5 +196,37 @@ namespace ntfysh_client e.SuppressKeyPress = true; } } + + private void serverUrl_KeyDown(object sender, KeyEventArgs e) + { + if (e.KeyData == Keys.Enter) + { + button1.PerformClick(); + e.SuppressKeyPress = true; + } + } + + private void username_KeyDown(object sender, KeyEventArgs e) + { + if (e.KeyData == Keys.Enter) + { + button1.PerformClick(); + e.SuppressKeyPress = true; + } + } + + private void password_KeyDown(object sender, KeyEventArgs e) + { + if (e.KeyData == Keys.Enter) + { + button1.PerformClick(); + 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 @@ - - - + diff --git a/ntfysh_client/SubscribedTopic.cs b/ntfysh_client/SubscribedTopic.cs new file mode 100644 index 0000000..b0593e1 --- /dev/null +++ b/ntfysh_client/SubscribedTopic.cs @@ -0,0 +1,37 @@ +using System; +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) + { + TopicId = topicId; + ServerUrl = serverUrl; + Username = username; + 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; + RunnerCanceller = runnerCanceller; + } + + public string TopicId { get; } + public string ServerUrl { get; } + public string? Username { get; } + public string? Password { get; } + + [JsonIgnore] + public Task? Runner { get; private set; } + + [JsonIgnore] + public CancellationTokenSource? RunnerCanceller { get; private set; } + } +} \ No newline at end of file diff --git a/ntfysh_client/ntfysh_client.csproj b/ntfysh_client/ntfysh_client.csproj index b6e42a2..5b9a3a3 100644 --- a/ntfysh_client/ntfysh_client.csproj +++ b/ntfysh_client/ntfysh_client.csproj @@ -1,114 +1,17 @@ - - - + + - Debug - AnyCPU - {5A18D152-D620-43FE-B844-DEF30CFA50EF} WinExe - ntfysh_client - ntfysh - v4.7.2 - 512 - true - true - - - AnyCPU - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - AnyCPU - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - + net6.0-windows + true + latest + enable NotificationHub.ico + ntfysh_client.Program + - - ..\packages\Newtonsoft.Json.13.0.1\lib\net45\Newtonsoft.Json.dll - - - - - - - - - - - - - + - - - Form - - - AboutBox.cs - - - Form - - - Form1.cs - - - - - - Form - - - SubscribeDialog.cs - - - AboutBox.cs - - - Form1.cs - - - ResXFileCodeGenerator - Resources.Designer.cs - Designer - - - True - Resources.resx - True - - - SubscribeDialog.cs - - - - SettingsSingleFileGenerator - Settings.Designer.cs - - - True - Settings.settings - True - - - - - - - - - + \ No newline at end of file