diff --git a/.gitignore b/.gitignore index 27b5c94..eba8177 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ _ReSharper*/ .vs/ #Nuget packages folder packages/ +/design/ diff --git a/KeePassTrayIconLockState/KeePassTrayIconLockState.csproj b/KeePassTrayIconLockState/KeePassTrayIconLockState.csproj index 4fad490..f418bed 100644 --- a/KeePassTrayIconLockState/KeePassTrayIconLockState.csproj +++ b/KeePassTrayIconLockState/KeePassTrayIconLockState.csproj @@ -49,6 +49,11 @@ + + True + True + Resources.resx + @@ -56,5 +61,23 @@ 2.0.18.1 + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + + + + + + + + + \ No newline at end of file diff --git a/KeePassTrayIconLockState/KeePassTrayIconLockStateExt.cs b/KeePassTrayIconLockState/KeePassTrayIconLockStateExt.cs index 4c1dd0b..b7f8a13 100644 --- a/KeePassTrayIconLockState/KeePassTrayIconLockStateExt.cs +++ b/KeePassTrayIconLockState/KeePassTrayIconLockStateExt.cs @@ -6,70 +6,80 @@ using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; -using KeePass.App; using KeePass.Plugins; using KeePass.Resources; -using KeePass.UI; using KoKo.Property; namespace KeePassTrayIconLockState { // ReSharper disable once UnusedType.Global - public class KeePassTrayIconLockStateExt: Plugin, IDisposable { + public class KeePassTrayIconLockStateExt: Plugin { internal static readonly TimeSpan STARTUP_DURATION = TimeSpan.FromMilliseconds(2000); - internal static readonly TimeSpan ANIMATION_PERIOD = TimeSpan.FromMilliseconds(700); private readonly IDictionary iconsByFileOpenState = new Dictionary(); - private readonly StoredProperty isDatabaseOpen = new StoredProperty(false); + private readonly StoredProperty databaseOpenState = new StoredProperty(DatabaseOpenState.CLOSED); private IPluginHost keePassHost = null!; - private Timer? animationTimer; - private Property trayIcon = null!; + private Property trayIcon = null!; public KeePassTrayIconLockStateExt() { - Icon lockedIcon = loadIcon(false); - Icon unlockedIcon = loadIcon(true); - - iconsByFileOpenState.Add(DatabaseOpenState.CLOSED, lockedIcon); - iconsByFileOpenState.Add(DatabaseOpenState.OPENING, lockedIcon); - iconsByFileOpenState.Add(DatabaseOpenState.OPEN, unlockedIcon); + iconsByFileOpenState.Add(DatabaseOpenState.CLOSED, Resources.locked); + iconsByFileOpenState.Add(DatabaseOpenState.OPENING, Resources.unlocking); + iconsByFileOpenState.Add(DatabaseOpenState.OPEN, Resources.unlocked); } public override bool Initialize(IPluginHost host) { keePassHost = host; - keePassHost.MainWindow.FileOpened += delegate { isDatabaseOpen.Value = true; }; - keePassHost.MainWindow.FileClosed += delegate { isDatabaseOpen.Value = false; }; - - ToolStripItem statusBarInfo = keePassHost.MainWindow.Controls.OfType().First().Items[1]; - Property statusBarText = new NativeReadableProperty(statusBarInfo, nameof(ToolStripItem.Text), nameof(ToolStripItem.TextChanged)); + keePassHost.MainWindow.FileOpened += delegate { databaseOpenState.Value = DatabaseOpenState.OPEN; }; + keePassHost.MainWindow.FileClosed += delegate { databaseOpenState.Value = DatabaseOpenState.CLOSED; }; + + ToolStripItemCollection statusBarItems = keePassHost.MainWindow.Controls.OfType().First().Items; + ToolStripItem statusBarInfo = statusBarItems[1]; + ToolStripItem progressBar = statusBarItems[2]; + Property statusBarText = new NativeReadableProperty(statusBarInfo, nameof(ToolStripItem.Text), nameof(ToolStripItem.TextChanged)); + Property isProgressBarVisible = new NativeReadableProperty(progressBar, nameof(ToolStripItem.Visible), nameof(ToolStripItem.VisibleChanged)); + + statusBarText.PropertyChanged += (sender, args) => { + if (args.NewValue == KPRes.OpeningDatabase2) { + /* + * When the status bar text changes to "Opening database...", set the db open state to OPENING. + */ + databaseOpenState.Value = DatabaseOpenState.OPENING; + } + }; - Property databaseOpenState = DerivedProperty.Create(isDatabaseOpen, statusBarText, (isOpen, statusText) => { - if (statusText == KPRes.OpeningDatabase2) { - return DatabaseOpenState.OPENING; - } else { - return isOpen ? DatabaseOpenState.OPEN : DatabaseOpenState.CLOSED; + isProgressBarVisible.PropertyChanged += (sender, args) => { + if (!args.NewValue && databaseOpenState.Value == DatabaseOpenState.OPENING) { + /* + * When the database is being opened and the progress bar gets hidden, it means the database was finished being decrypted, but was it successful or unsuccessful? + * Sadly there is no good way to tell, so instead we wait 100 ms for the FileOpen event to be fired. + * If it is fired, the db open state moves from OPENING to OPEN. + * Otherwise, assume that the database failed to decrypt and set the db open state to CLOSED. + */ + Task.Delay(100).ContinueWith(task => { + if (databaseOpenState.Value == DatabaseOpenState.OPENING) { // failed to decrypt, otherwise this would have been set to OPEN by the FileOpen event above. + databaseOpenState.Value = DatabaseOpenState.CLOSED; + } + }); + + } else if (args.NewValue && databaseOpenState.Value == DatabaseOpenState.CLOSED && statusBarText.Value == KPRes.OpeningDatabase2) { + /* + * When the database is closed and the status bar says "Opening database..." and the progress bar is shown, it means the user already failed the previous decryption attempt + * and is retrying after submitting another password, so set the db opening state to OPENING. + */ + databaseOpenState.Value = DatabaseOpenState.OPENING; } - }); + }; - trayIcon = DerivedProperty.Create(databaseOpenState, isOpen => new TrayIcon(iconsByFileOpenState[isOpen], isOpen != DatabaseOpenState.CLOSED)); + trayIcon = DerivedProperty.Create(databaseOpenState, openState => new TrayIcon(iconsByFileOpenState[openState], openState != DatabaseOpenState.CLOSED)); trayIcon.PropertyChanged += (sender, args) => renderTrayIcon(); - animationTimer = new Timer { Enabled = false, Interval = (int) ANIMATION_PERIOD.TotalMilliseconds }; - bool animationShowClosedIcon = false; - animationTimer.Tick += delegate { - keePassHost.MainWindow.MainNotifyIcon.Icon = iconsByFileOpenState[animationShowClosedIcon ? DatabaseOpenState.CLOSED : DatabaseOpenState.OPEN]; - animationShowClosedIcon ^= true; - }; - - databaseOpenState.PropertyChanged += (sender, args) => { - animationShowClosedIcon = false; - animationTimer.Enabled = args.NewValue == DatabaseOpenState.OPENING; - }; - - // Give KeePass time to stop setting its own icon + /* + * KeePass sets its own icon at some indeterminate time after startup, so repeatedly set our own icon every 8 ms for 2 seconds to make sure our icon isn't overridden. + */ Timer startupTimer = new Timer { Enabled = true, Interval = 8 }; startupTimer.Tick += delegate { renderTrayIcon(); }; Task.Delay(STARTUP_DURATION).ContinueWith(task => startupTimer.Stop()); @@ -78,26 +88,15 @@ public override bool Initialize(IPluginHost host) { return true; } - private static Icon loadIcon(bool fileOpen) { - AppIconType appIconType = fileOpen ? AppIconType.QuadNormal : AppIconType.QuadLocked; - return AppIcons.Get(appIconType, UIUtil.GetSmallIconSize(), Color.Empty); - } - private void renderTrayIcon() { - TrayIcon icon = trayIcon.Value; - keePassHost.MainWindow.MainNotifyIcon.Icon = icon.image; - keePassHost.MainWindow.MainNotifyIcon.Visible = icon.isVisible; - } - - public override Image SmallIcon => iconsByFileOpenState[DatabaseOpenState.CLOSED].ToBitmap(); + TrayIcon iconToRender = trayIcon.Value; + NotifyIcon keepassIcon = keePassHost.MainWindow.MainNotifyIcon; - public override void Terminate() { - Dispose(); + keepassIcon.Icon = iconToRender.image; + keepassIcon.Visible = iconToRender.isVisible; } - public void Dispose() { - animationTimer?.Dispose(); - } + public override Image SmallIcon => Resources.plugin_image; } diff --git a/KeePassTrayIconLockState/Resources.Designer.cs b/KeePassTrayIconLockState/Resources.Designer.cs new file mode 100644 index 0000000..492e4f9 --- /dev/null +++ b/KeePassTrayIconLockState/Resources.Designer.cs @@ -0,0 +1,103 @@ +//------------------------------------------------------------------------------ +// +// 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 KeePassTrayIconLockState { + 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("KeePassTrayIconLockState.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; + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Icon similar to (Icon). + /// + internal static System.Drawing.Icon locked { + get { + object obj = ResourceManager.GetObject("locked", resourceCulture); + return ((System.Drawing.Icon)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap plugin_image { + get { + object obj = ResourceManager.GetObject("plugin_image", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Icon similar to (Icon). + /// + internal static System.Drawing.Icon unlocked { + get { + object obj = ResourceManager.GetObject("unlocked", resourceCulture); + return ((System.Drawing.Icon)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Icon similar to (Icon). + /// + internal static System.Drawing.Icon unlocking { + get { + object obj = ResourceManager.GetObject("unlocking", resourceCulture); + return ((System.Drawing.Icon)(obj)); + } + } + } +} diff --git a/KeePassTrayIconLockState/Resources.resx b/KeePassTrayIconLockState/Resources.resx new file mode 100644 index 0000000..6c7922b --- /dev/null +++ b/KeePassTrayIconLockState/Resources.resx @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Resources\locked.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + Resources\plugin image.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + Resources\unlocked.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + resources\unlocking.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + \ No newline at end of file diff --git a/KeePassTrayIconLockState/Resources/locked.ico b/KeePassTrayIconLockState/Resources/locked.ico new file mode 100644 index 0000000..b21666b Binary files /dev/null and b/KeePassTrayIconLockState/Resources/locked.ico differ diff --git a/KeePassTrayIconLockState/Resources/plugin image.png b/KeePassTrayIconLockState/Resources/plugin image.png new file mode 100644 index 0000000..690302f Binary files /dev/null and b/KeePassTrayIconLockState/Resources/plugin image.png differ diff --git a/KeePassTrayIconLockState/Resources/unlocked.ico b/KeePassTrayIconLockState/Resources/unlocked.ico new file mode 100644 index 0000000..bd2a9fc Binary files /dev/null and b/KeePassTrayIconLockState/Resources/unlocked.ico differ diff --git a/KeePassTrayIconLockState/Resources/unlocking.ico b/KeePassTrayIconLockState/Resources/unlocking.ico new file mode 100644 index 0000000..bec0276 Binary files /dev/null and b/KeePassTrayIconLockState/Resources/unlocking.ico differ diff --git a/Test/KeePassTrayIconLockStateExtTest.cs b/Test/KeePassTrayIconLockStateExtTest.cs index 06b8a79..eafca16 100644 --- a/Test/KeePassTrayIconLockStateExtTest.cs +++ b/Test/KeePassTrayIconLockStateExtTest.cs @@ -4,20 +4,20 @@ using System.Collections.Generic; using System.Drawing; using System.Linq; -using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; -using KeePass.App; +using FluentAssertions; using KeePass.Forms; using KeePass.Plugins; -using KeePass.UI; using KeePassTrayIconLockState; using Telerik.JustMock; using Xunit; namespace Test { - public class KeePassTrayIconLockStateExtTest: IDisposable { + public class KeePassTrayIconLockStateExtTest { + + private static readonly ImageConverter IMAGE_CONVERTER = new ImageConverter(); private readonly KeePassTrayIconLockStateExt plugin = new KeePassTrayIconLockStateExt(); @@ -26,14 +26,7 @@ public class KeePassTrayIconLockStateExtTest: IDisposable { private readonly NotifyIcon mainNotifyIcon; private readonly ToolStripStatusLabel statusPartInfo; - private readonly TimeSpan loadDelay = TimeSpan.FromTicks(KeePassTrayIconLockStateExt.STARTUP_DURATION.Ticks * 2); - private readonly TimeSpan animationDelay = KeePassTrayIconLockStateExt.ANIMATION_PERIOD; - private readonly Icon smallLockedIcon = AppIcons.Get(AppIconType.QuadLocked, UIUtil.GetSmallIconSize(), Color.Empty); - private readonly Icon smallUnlockedIcon = AppIcons.Get(AppIconType.QuadNormal, UIUtil.GetSmallIconSize(), Color.Empty); - - public void Dispose() { - plugin.Dispose(); - } + private readonly TimeSpan loadDelay = TimeSpan.FromTicks(KeePassTrayIconLockStateExt.STARTUP_DURATION.Ticks * 2); public KeePassTrayIconLockStateExtTest() { Assert.True(Mock.IsProfilerEnabled, "These tests require the JustMock Profiler to be enabled because the " + @@ -41,7 +34,7 @@ public KeePassTrayIconLockStateExtTest() { keePassHost = Mock.Create(); mainWindow = Mock.Create(); - mainNotifyIcon = Mock.Create(); + mainNotifyIcon = new NotifyIcon(); Control.ControlCollection mainWindowControls = Mock.Create(); StatusStrip statusStrip = new StatusStrip(); @@ -56,68 +49,57 @@ public KeePassTrayIconLockStateExtTest() { } [Fact] - public async void renderBrieflyAfterLoad() { + public void startup() { plugin.Initialize(keePassHost); - Mock.AssertSet(() => mainNotifyIcon.Icon = smallLockedIcon, Occurs.Never()); - Mock.AssertSet(() => mainNotifyIcon.Visible = false, Occurs.Never()); - - await Task.Delay(loadDelay); - - Mock.AssertSet(() => mainNotifyIcon.Icon = smallLockedIcon, Occurs.Once()); - Mock.AssertSet(() => mainNotifyIcon.Visible = false, Occurs.Once()); + mainNotifyIcon.Visible.Should().BeFalse(); + assertIconsEqual(mainNotifyIcon.Icon, Resources.locked); } [Fact] - public async void animateWhileDecrypting() { + public async void decrypting() { plugin.Initialize(keePassHost); await Task.Delay(loadDelay); - Mock.AssertSet(() => mainNotifyIcon.Icon = smallLockedIcon, Occurs.Once(), "icon should start locked"); - Mock.AssertSet(() => mainNotifyIcon.Visible = false, Occurs.Once(), "icon should start invisible"); statusPartInfo.Text = "Opening database..."; - await Task.Delay((int) (animationDelay.TotalMilliseconds / 2)); - - Mock.AssertSet(() => mainNotifyIcon.Icon = smallUnlockedIcon, Occurs.Once(), "immediately after opening, icon should be unlocked"); - Mock.AssertSet(() => mainNotifyIcon.Visible = true, Occurs.Once(), "immediately after opening, icon should be visible"); - - await Task.Delay(animationDelay); - - Mock.AssertSet(() => mainNotifyIcon.Icon = smallLockedIcon, Occurs.Exactly(2)); - - await Task.Delay(animationDelay); - - Mock.AssertSet(() => mainNotifyIcon.Icon = smallUnlockedIcon, Occurs.Exactly(2)); - - await Task.Delay(animationDelay); - - Mock.AssertSet(() => mainNotifyIcon.Icon = smallLockedIcon, Occurs.Exactly(3)); - - Mock.AssertSet(() => mainNotifyIcon.Visible = false, Occurs.Never()); + mainNotifyIcon.Visible.Should().BeTrue(); + assertIconsEqual(mainNotifyIcon.Icon, Resources.unlocking); } [Fact] - public async void unlockAfterOpeningFile() { + public async void unlocked() { plugin.Initialize(keePassHost); await Task.Delay(loadDelay); Mock.Raise(() => mainWindow.FileOpened += null, new FileOpenedEventArgs(null)); - Mock.AssertSet(() => mainNotifyIcon.Icon = smallUnlockedIcon, Occurs.Once()); - Mock.AssertSet(() => mainNotifyIcon.Visible = true, Occurs.Once()); + mainNotifyIcon.Visible.Should().BeTrue(); + assertIconsEqual(mainNotifyIcon.Icon, Resources.unlocked); } [Fact] - public async void lockAfterClosingFile() { + public async void locked() { plugin.Initialize(keePassHost); await Task.Delay(loadDelay); Mock.Raise(() => mainWindow.FileOpened += null, new FileOpenedEventArgs(null)); Mock.Raise(() => mainWindow.FileClosed += null, new FileClosedEventArgs(null, FileEventFlags.Locking)); - Mock.AssertSet(() => mainNotifyIcon.Icon = smallLockedIcon, Occurs.Exactly(2)); - Mock.AssertSet(() => mainNotifyIcon.Visible = false, Occurs.Exactly(2)); + mainNotifyIcon.Visible.Should().BeFalse(); + assertIconsEqual(mainNotifyIcon.Icon, Resources.locked); + } + + internal static void assertIconsEqual(Icon actual, Icon expected) { + getIconBytes(actual.ToBitmap()).Should().Equal(getIconBytes(expected.ToBitmap())); + } + + internal static void assertIconsEqual(Image actual, Icon expected) { + getIconBytes(actual).Should().Equal(getIconBytes(expected.ToBitmap())); + } + + private static IEnumerable? getIconBytes(Image image) { + return (byte[]?) IMAGE_CONVERTER.ConvertTo(image, typeof(byte[])); } } diff --git a/Test/PluginTest.cs b/Test/PluginTest.cs index b1a8bd2..3501bc8 100644 --- a/Test/PluginTest.cs +++ b/Test/PluginTest.cs @@ -1,12 +1,9 @@ #nullable enable using System.Diagnostics; -using System.Drawing; using System.IO; using FluentAssertions; -using KeePass.App; using KeePass.Plugins; -using KeePass.UI; using KeePassTrayIconLockState; using Xunit; @@ -16,7 +13,6 @@ public class PluginTest { private readonly KeePassTrayIconLockStateExt plugin = new KeePassTrayIconLockStateExt(); - private readonly Icon smallLockedIcon = AppIcons.Get(AppIconType.QuadLocked, UIUtil.GetSmallIconSize(), Color.Empty); [Fact] public void derivesFromPluginSuperclass() { @@ -49,10 +45,7 @@ public void isProductNameSetCorrectly() { [Fact] public void pluginIcon() { - var imageConverter = new ImageConverter(); - var actual = (byte[]) imageConverter.ConvertTo(plugin.SmallIcon, typeof(byte[])); - var expected = (byte[]) imageConverter.ConvertTo(smallLockedIcon.ToBitmap(), typeof(byte[])); - actual.Should().Equal(expected); + KeePassTrayIconLockStateExtTest.assertIconsEqual(plugin.SmallIcon, Resources.locked); } }