diff --git a/SinglePass.Language/Properties/Resources.Designer.cs b/SinglePass.Language/Properties/Resources.Designer.cs index 99845ba..b729006 100644 --- a/SinglePass.Language/Properties/Resources.Designer.cs +++ b/SinglePass.Language/Properties/Resources.Designer.cs @@ -690,6 +690,33 @@ public static string TextCopied { } } + /// + /// Looks up a localized string similar to Theme. + /// + public static string Theme { + get { + return ResourceManager.GetString("Theme", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to to paste login. + /// + public static string ToPasteLogin { + get { + return ResourceManager.GetString("ToPasteLogin", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to to paste password. + /// + public static string ToPastePassword { + get { + return ResourceManager.GetString("ToPastePassword", resourceCulture); + } + } + /// /// Looks up a localized string similar to Upload. /// diff --git a/SinglePass.Language/Properties/Resources.resx b/SinglePass.Language/Properties/Resources.resx index 153bdcd..457f190 100644 --- a/SinglePass.Language/Properties/Resources.resx +++ b/SinglePass.Language/Properties/Resources.resx @@ -325,4 +325,13 @@ Can't merge credentials + + to paste login + + + to paste password + + + Theme + \ No newline at end of file diff --git a/SinglePass.Language/Properties/Resources.ru.resx b/SinglePass.Language/Properties/Resources.ru.resx index 8e19b14..59c2747 100644 --- a/SinglePass.Language/Properties/Resources.ru.resx +++ b/SinglePass.Language/Properties/Resources.ru.resx @@ -345,4 +345,13 @@ Нет изменений + + для вставки логина + + + для вставки пароля + + + Тема + \ No newline at end of file diff --git a/SinglePass.WPF/App.xaml.cs b/SinglePass.WPF/App.xaml.cs index c9374ed..0dd09b9 100644 --- a/SinglePass.WPF/App.xaml.cs +++ b/SinglePass.WPF/App.xaml.cs @@ -16,9 +16,11 @@ using SinglePass.WPF.Settings; using SinglePass.WPF.ViewModels; using SinglePass.WPF.Views; +using SinglePass.WPF.Views.Windows; using System; using System.Threading; using System.Windows; +using System.Windows.Interop; namespace SinglePass.WPF { @@ -85,8 +87,9 @@ private static IServiceProvider ConfigureServices(IConfiguration configuration) services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + // Popup services.AddTransient(); services.AddTransient(); @@ -132,6 +135,9 @@ private void Application_Startup(object sender, StartupEventArgs e) var welcomeWindow = new WelcomeWindow(); welcomeWindow.Show(); + var hiw = new HiddenInterprocessWindow(); + hiw.InitWithoutShowing(); + _logger = Services.GetService>(); _logger.LogInformation("Log session started!"); @@ -165,6 +171,7 @@ private void Application_Startup(object sender, StartupEventArgs e) } else { + InterprocessHelper.ShowMainWindow(); Shutdown(); } } diff --git a/SinglePass.WPF/Authorization/Brokers/BaseAuthorizationBroker.cs b/SinglePass.WPF/Authorization/Brokers/BaseAuthorizationBroker.cs index f5adc09..1b88637 100644 --- a/SinglePass.WPF/Authorization/Brokers/BaseAuthorizationBroker.cs +++ b/SinglePass.WPF/Authorization/Brokers/BaseAuthorizationBroker.cs @@ -1,10 +1,6 @@ using SinglePass.WPF.Authorization.Helpers; -using SinglePass.WPF.Authorization.Responses; using SinglePass.WPF.Authorization.TokenHolders; using System; -using System.Collections.Specialized; -using System.Linq; -using System.Net; using System.Net.Http; using System.Text; using System.Threading; @@ -16,6 +12,7 @@ public abstract class BaseAuthorizationBroker : IAuthorizationBroker { private readonly IHttpClientFactory _httpClientFactory; + protected string RedirectUri { get; set; } public ITokenHolder TokenHolder { get; } public BaseAuthorizationBroker(IHttpClientFactory httpClientFactory, ITokenHolder tokenHolder) @@ -26,24 +23,24 @@ public BaseAuthorizationBroker(IHttpClientFactory httpClientFactory, ITokenHolde public async Task AuthorizeAsync(CancellationToken cancellationToken) { - var redirectUri = BuildRedirectUri(); - var authorizationUri = BuildAuthorizationUri(redirectUri); - using var listener = OAuthHelper.StartListener(redirectUri); + BuildRedirectUri(); + var authorizationUri = BuildAuthorizationUri(); + using var listener = OAuthHelper.StartListener(RedirectUri); OAuthHelper.OpenBrowser(authorizationUri); - var response = await GetResponseFromListener(listener, cancellationToken); + var response = await OAuthHelper.GetResponseFromListener(listener, BuildClosePageResponse(), cancellationToken); if (string.IsNullOrWhiteSpace(response?.Code)) { throw new Exception("Code was empty!"); } - var tokenResponse = await RetrieveToken(response.Code, redirectUri, cancellationToken); + var tokenResponse = await RetrieveToken(response.Code, cancellationToken); await TokenHolder.SetAndSaveToken(tokenResponse, cancellationToken); } public async Task RefreshAccessToken(CancellationToken cancellationToken) { var client = _httpClientFactory.CreateClient(); - var refreshTokenEndpointUri = BuildRefreshAccessTokenEndpointUri(); - var postData = BuildRequestForRefreshToken(); + var refreshTokenEndpointUri = BuildRefreshTokenEndpointUri(); + var postData = BuildRefreshTokenRequest(); var request = new HttpRequestMessage(HttpMethod.Post, new Uri(refreshTokenEndpointUri)) { Content = new StringContent(postData, Encoding.UTF8, "application/x-www-form-urlencoded") @@ -58,61 +55,20 @@ public async Task RevokeToken(CancellationToken cancellationToken) { await TokenHolder.RemoveToken(); var client = _httpClientFactory.CreateClient(); - var revokeTokenEndpointUri = BuildRevokeTokenEndpointUri(); + var revokeTokenEndpointUri = BuildTokenRevokeEndpointUri(); var stringContent = new StringContent(string.Empty, Encoding.UTF8, "application/x-www-form-urlencoded"); var response = await client.PostAsync(revokeTokenEndpointUri, stringContent, cancellationToken); using var content = response.Content; var json = await content.ReadAsStringAsync(cancellationToken); } - private async Task GetResponseFromListener(HttpListener listener, CancellationToken cancellationToken) - { - HttpListenerContext context; - // Set up cancellation. HttpListener.GetContextAsync() doesn't accept a cancellation token, - // the HttpListener needs to be stopped which immediately aborts the GetContextAsync() call. - using (cancellationToken.Register(listener.Stop)) - { - // Wait to get the authorization code response. - try - { - context = await listener.GetContextAsync().ConfigureAwait(false); - } - catch (Exception) when (cancellationToken.IsCancellationRequested) - { - cancellationToken.ThrowIfCancellationRequested(); - // Next line will never be reached because cancellation will always have been requested in this catch block. - // But it's required to satisfy compiler. - throw new InvalidOperationException(); - } - catch - { - throw; - } - } - NameValueCollection coll = context.Request.QueryString; - - // Write a "close" response. - var bytes = Encoding.UTF8.GetBytes(BuildClosePageResponse()); - context.Response.ContentLength64 = bytes.Length; - context.Response.SendChunked = false; - context.Response.KeepAlive = false; - var output = context.Response.OutputStream; - await output.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); - await output.FlushAsync(cancellationToken).ConfigureAwait(false); - output.Close(); - context.Response.Close(); - - // Create a new response URL with a dictionary that contains all the response query parameters. - return new AuthorizationCodeResponseUrl(coll.AllKeys.ToDictionary(k => k, k => coll[k])); - } - - private async Task RetrieveToken(string code, string redirectUri, CancellationToken cancellationToken) + private async Task RetrieveToken(string code, CancellationToken cancellationToken) { string result; var client = _httpClientFactory.CreateClient(); var tokenEndpointUri = BuildTokenEndpointUri(); - var postData = BuildRequestForToken(code, redirectUri); + var postData = BuildTokenRequest(code); var request = new HttpRequestMessage(HttpMethod.Post, new Uri(tokenEndpointUri)) { Content = new StringContent(postData, Encoding.UTF8, "application/x-www-form-urlencoded") @@ -130,12 +86,12 @@ protected virtual string BuildClosePageResponse() return "Authorization success, you can return to application"; } - protected abstract string BuildRedirectUri(); - protected abstract string BuildAuthorizationUri(string redirectUri); + protected abstract void BuildRedirectUri(); + protected abstract string BuildAuthorizationUri(); protected abstract string BuildTokenEndpointUri(); - protected abstract string BuildRequestForToken(string code, string redirectUri); - protected abstract string BuildRefreshAccessTokenEndpointUri(); - protected abstract string BuildRequestForRefreshToken(); - protected abstract string BuildRevokeTokenEndpointUri(); + protected abstract string BuildTokenRequest(string code); + protected abstract string BuildRefreshTokenEndpointUri(); + protected abstract string BuildRefreshTokenRequest(); + protected abstract string BuildTokenRevokeEndpointUri(); } } diff --git a/SinglePass.WPF/Authorization/Brokers/GoogleAuthorizationBroker.cs b/SinglePass.WPF/Authorization/Brokers/GoogleAuthorizationBroker.cs index 8b9e9d2..ce38719 100644 --- a/SinglePass.WPF/Authorization/Brokers/GoogleAuthorizationBroker.cs +++ b/SinglePass.WPF/Authorization/Brokers/GoogleAuthorizationBroker.cs @@ -25,7 +25,7 @@ public GoogleAuthorizationBroker( _config = options.Value; } - protected override string BuildAuthorizationUri(string redirectUri) + protected override string BuildAuthorizationUri() { return "https://accounts.google.com/o/oauth2/v2/auth?" + $"scope={HttpUtility.UrlEncode(_config.Scopes)}&" + @@ -33,22 +33,22 @@ protected override string BuildAuthorizationUri(string redirectUri) "include_granted_scopes=true&" + "response_type=code&" + "state=state_parameter_passthrough_value&" + - $"redirect_uri={HttpUtility.UrlEncode(redirectUri)}&" + + $"redirect_uri={HttpUtility.UrlEncode(RedirectUri)}&" + $"client_id={_config.ClientId}"; } - protected override string BuildRedirectUri() + protected override void BuildRedirectUri() { var unusedPort = OAuthHelper.GetRandomUnusedPort(); - return $"http://localhost:{unusedPort}/"; + RedirectUri = $"http://localhost:{unusedPort}/"; } - protected override string BuildRefreshAccessTokenEndpointUri() + protected override string BuildRefreshTokenEndpointUri() { return "https://oauth2.googleapis.com/token"; } - protected override string BuildRequestForRefreshToken() + protected override string BuildRefreshTokenRequest() { return $"client_id={_config.ClientId}&" + $"client_secret={_config.ClientSecret}&" + @@ -56,12 +56,12 @@ protected override string BuildRequestForRefreshToken() $"grant_type=refresh_token"; } - protected override string BuildRequestForToken(string code, string redirectUri) + protected override string BuildTokenRequest(string code) { return $"code={code}&" + $"client_id={_config.ClientId}&" + $"client_secret={_config.ClientSecret}&" + - $"redirect_uri={HttpUtility.UrlEncode(redirectUri)}&" + + $"redirect_uri={HttpUtility.UrlEncode(RedirectUri)}&" + $"grant_type=authorization_code"; } @@ -70,7 +70,7 @@ protected override string BuildTokenEndpointUri() return "https://oauth2.googleapis.com/token"; } - protected override string BuildRevokeTokenEndpointUri() + protected override string BuildTokenRevokeEndpointUri() { return $"https://oauth2.googleapis.com/revoke?token={TokenHolder.Token.RefreshToken}"; } diff --git a/SinglePass.WPF/Authorization/Brokers/IAuthorizationBroker.cs b/SinglePass.WPF/Authorization/Brokers/IAuthorizationBroker.cs index c003366..099672e 100644 --- a/SinglePass.WPF/Authorization/Brokers/IAuthorizationBroker.cs +++ b/SinglePass.WPF/Authorization/Brokers/IAuthorizationBroker.cs @@ -1,6 +1,6 @@ -using System.Threading; +using SinglePass.WPF.Authorization.TokenHolders; +using System.Threading; using System.Threading.Tasks; -using SinglePass.WPF.Authorization.TokenHolders; namespace SinglePass.WPF.Authorization.Brokers { diff --git a/SinglePass.WPF/Authorization/Helpers/OAuthHelper.cs b/SinglePass.WPF/Authorization/Helpers/OAuthHelper.cs index 9820647..1b91771 100644 --- a/SinglePass.WPF/Authorization/Helpers/OAuthHelper.cs +++ b/SinglePass.WPF/Authorization/Helpers/OAuthHelper.cs @@ -1,6 +1,13 @@ -using System.Diagnostics; +using SinglePass.WPF.Authorization.Responses; +using System; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Linq; using System.Net; using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; namespace SinglePass.WPF.Authorization.Helpers { @@ -34,5 +41,49 @@ public static void OpenBrowser(string uri) uri = System.Text.RegularExpressions.Regex.Replace(uri, @"(\\+)$", @"$1$1"); Process.Start(new ProcessStartInfo("cmd", $"/c start \"\" \"{uri}\"") { CreateNoWindow = true }); } + + public static async Task GetResponseFromListener( + HttpListener listener, + string responseHtmlText, + CancellationToken cancellationToken = default) + { + HttpListenerContext context; + // Set up cancellation. HttpListener.GetContextAsync() doesn't accept a cancellation token, + // the HttpListener needs to be stopped which immediately aborts the GetContextAsync() call. + using (cancellationToken.Register(listener.Stop)) + { + // Wait to get the authorization code response. + try + { + context = await listener.GetContextAsync().ConfigureAwait(false); + } + catch (Exception) when (cancellationToken.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + // Next line will never be reached because cancellation will always have been requested in this catch block. + // But it's required to satisfy compiler. + throw new InvalidOperationException(); + } + catch + { + throw; + } + } + NameValueCollection coll = context.Request.QueryString; + + // Write a "close" response. + var bytes = Encoding.UTF8.GetBytes(responseHtmlText); + context.Response.ContentLength64 = bytes.Length; + context.Response.SendChunked = false; + context.Response.KeepAlive = false; + var output = context.Response.OutputStream; + await output.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Close(); + context.Response.Close(); + + // Create a new response URL with a dictionary that contains all the response query parameters. + return new AuthorizationCodeResponseUrl(coll.AllKeys.ToDictionary(k => k, k => coll[k])); + } } } diff --git a/SinglePass.WPF/Authorization/TokenHolders/ITokenHolder.cs b/SinglePass.WPF/Authorization/TokenHolders/ITokenHolder.cs index 52cf023..f56a00b 100644 --- a/SinglePass.WPF/Authorization/TokenHolders/ITokenHolder.cs +++ b/SinglePass.WPF/Authorization/TokenHolders/ITokenHolder.cs @@ -1,6 +1,6 @@ -using System.Threading; +using SinglePass.WPF.Authorization.Responses; +using System.Threading; using System.Threading.Tasks; -using SinglePass.WPF.Authorization.Responses; namespace SinglePass.WPF.Authorization.TokenHolders { diff --git a/SinglePass.WPF/Converters/ModeToIsReadonlyConverter.cs b/SinglePass.WPF/Converters/ModeToIsReadonlyConverter.cs index 3b02cd8..559c0b9 100644 --- a/SinglePass.WPF/Converters/ModeToIsReadonlyConverter.cs +++ b/SinglePass.WPF/Converters/ModeToIsReadonlyConverter.cs @@ -5,21 +5,20 @@ namespace SinglePass.WPF.Converters { - [ValueConversion(typeof(CredentialsDialogMode), typeof(bool))] + [ValueConversion(typeof(CredentialDetailsMode), typeof(bool))] internal class ModeToIsReadonlyConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - if (value is CredentialsDialogMode mode) + if (value is CredentialDetailsMode mode) { switch (mode) { - case CredentialsDialogMode.New: + case CredentialDetailsMode.New: + case CredentialDetailsMode.Edit: return false; - case CredentialsDialogMode.View: + case CredentialDetailsMode.View: return true; - case CredentialsDialogMode.Edit: - return false; default: break; } diff --git a/SinglePass.WPF/Converters/ModeToVisibilityConverter.cs b/SinglePass.WPF/Converters/ModeToVisibilityConverter.cs index 37755e2..012bfa4 100644 --- a/SinglePass.WPF/Converters/ModeToVisibilityConverter.cs +++ b/SinglePass.WPF/Converters/ModeToVisibilityConverter.cs @@ -6,23 +6,22 @@ namespace SinglePass.WPF.Converters { - [ValueConversion(typeof(CredentialsDialogMode), typeof(Visibility))] + [ValueConversion(typeof(CredentialDetailsMode), typeof(Visibility))] internal class ModeToVisibilityConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - if (value is CredentialsDialogMode mode) + if (value is CredentialDetailsMode mode) { var inverse = parameter is string sparam && sparam == "inverse"; switch (mode) { - case CredentialsDialogMode.New: + case CredentialDetailsMode.New: + case CredentialDetailsMode.Edit: return inverse ? Visibility.Collapsed : Visibility.Visible; - case CredentialsDialogMode.View: + case CredentialDetailsMode.View: return inverse ? Visibility.Visible : Visibility.Collapsed; - case CredentialsDialogMode.Edit: - return inverse ? Visibility.Collapsed : Visibility.Visible; default: break; } diff --git a/SinglePass.WPF/Enums/CredentialDialogMode.cs b/SinglePass.WPF/Enums/CredentialDetailsMode.cs similarity index 70% rename from SinglePass.WPF/Enums/CredentialDialogMode.cs rename to SinglePass.WPF/Enums/CredentialDetailsMode.cs index 985e28a..40ec851 100644 --- a/SinglePass.WPF/Enums/CredentialDialogMode.cs +++ b/SinglePass.WPF/Enums/CredentialDetailsMode.cs @@ -1,6 +1,6 @@ namespace SinglePass.WPF.Enums { - public enum CredentialsDialogMode + public enum CredentialDetailsMode { Edit, New, diff --git a/SinglePass.WPF/Enums/CustomWindowsMessages.cs b/SinglePass.WPF/Enums/CustomWindowsMessages.cs new file mode 100644 index 0000000..78ed835 --- /dev/null +++ b/SinglePass.WPF/Enums/CustomWindowsMessages.cs @@ -0,0 +1,12 @@ +using static SinglePass.WPF.Helpers.WinApiProvider; + +namespace SinglePass.WPF.Enums +{ + internal enum CustomWindowsMessages : uint + { + /// + /// Shows main window. + /// + WM_SHOW_MAIN_WINDOW = SystemWindowsMessages.WM_USER + 1, + } +} diff --git a/SinglePass.WPF/Helpers/Constants.cs b/SinglePass.WPF/Helpers/Constants.cs index 8837f9f..6a3b804 100644 --- a/SinglePass.WPF/Helpers/Constants.cs +++ b/SinglePass.WPF/Helpers/Constants.cs @@ -5,6 +5,8 @@ namespace SinglePass.WPF.Helpers { public static class Constants { + public const string InterprocessWindowName = "HiddenInterprocessWindow"; + public const string ProcessName = "SinglePass.WPF"; public const string AppName = "SinglePass"; public const string PasswordsFileName = "singlePass.dat"; public const string CommonSettingsFileName = "commonSettings.json"; diff --git a/SinglePass.WPF/Helpers/InterprocessHelper.cs b/SinglePass.WPF/Helpers/InterprocessHelper.cs new file mode 100644 index 0000000..08e71f0 --- /dev/null +++ b/SinglePass.WPF/Helpers/InterprocessHelper.cs @@ -0,0 +1,37 @@ +using SinglePass.WPF.Enums; +using System; +using System.Diagnostics; +using System.Linq; + +namespace SinglePass.WPF.Helpers +{ + internal static class InterprocessHelper + { + public static void ShowMainWindow() + { + var currentProcessId = System.Environment.ProcessId; + var processes = Process + .GetProcessesByName(Constants.ProcessName) + .Where(p => !p.Id.Equals(currentProcessId)) + .ToList(); + if (processes.Count == 0) + return; + + var spProcess = processes[0]; + var processWindowHandles = WinApiProvider.EnumerateProcessWindowHandles(spProcess.Id); + foreach (var processWindowHandle in processWindowHandles) + { + var windowCaption = WinApiProvider.GetWindowText(processWindowHandle); + if (windowCaption.Equals(Constants.InterprocessWindowName)) + { + WinApiProvider.PostMessage( + processWindowHandle, + (uint)CustomWindowsMessages.WM_SHOW_MAIN_WINDOW, + IntPtr.Zero, + IntPtr.Zero); + break; + } + } + } + } +} diff --git a/SinglePass.WPF/Helpers/WinApiProvider.cs b/SinglePass.WPF/Helpers/WinApiProvider.cs index 5d97800..1466054 100644 --- a/SinglePass.WPF/Helpers/WinApiProvider.cs +++ b/SinglePass.WPF/Helpers/WinApiProvider.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; +using System.Diagnostics; using System.Runtime.InteropServices; +using System.Text; namespace SinglePass.WPF.Helpers { @@ -80,6 +83,77 @@ public static IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong) [DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")] private static extern IntPtr SetWindowLongPtr64(IntPtr hWnd, int nIndex, IntPtr dwNewLong); + /// + /// Sends the specified message to a window or windows. + /// + /// A handle to the window whose window procedure will receive the message. + /// The message to be sent. + /// Additional message-specific information. + /// second message parameter + /// + [DllImport("user32", SetLastError = true)] + public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); + + /// + /// Places (posts) a message in the message queue associated with the thread + /// that created the specified window and returns without waiting for the + /// thread to process the message. + /// + /// A handle to the window whose window procedure will receive the message. + /// The message to be sent. + /// Additional message-specific information. + /// second message parameter + /// + [return: MarshalAs(UnmanagedType.Bool)] + [DllImport("user32.dll", SetLastError = true)] + public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool ShowWindow(IntPtr hWnd, ShowWindowCommands nCmdShow); + + public delegate bool EnumThreadDelegate(IntPtr hWnd, IntPtr lParam); + + [DllImport("user32.dll")] + static extern bool EnumThreadWindows(int dwThreadId, EnumThreadDelegate lpfn, IntPtr lParam); + + public static IEnumerable EnumerateProcessWindowHandles(int processId) + { + var handles = new List(); + + foreach (ProcessThread thread in Process.GetProcessById(processId).Threads) + { + EnumThreadWindows( + thread.Id, + (hWnd, lParam) => { handles.Add(hWnd); return true; }, + IntPtr.Zero); + } + + return handles; + } + + /// + /// Retrieves the WindowText of the window given by the handle. + /// + /// The windows handle + /// A stringbuilder object wich receives the window text + /// The max length of the text to retrieve, usually 260 + /// Returns the length of chars received. + [DllImport("user32", CharSet = CharSet.Unicode)] + public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + /// + /// Retrieves the WindowText of the window given by the handle. + /// + /// The windows handle + /// Window text. + public static string GetWindowText(IntPtr hWnd) + { + var windowName = new StringBuilder(260); + _ = GetWindowText(hWnd, windowName, 260); + return windowName.ToString(); + } + public const uint SWP_NOZORDER = 0x0004; public const int CHILDID_SELF = 0; @@ -150,5 +224,104 @@ public static implicit operator POINT(System.Drawing.Point p) return new POINT(p.X, p.Y); } } + + /// + /// System windows message codes + /// + public enum SystemWindowsMessages : uint + { + /// + /// This message is sent when a window is being activated or deactivated. This message is sent first to the window procedure of the top-level window being deactivated; it is then sent to the window procedure of the top-level window being activated. + /// + WM_ACTIVATE = 0x0006, + + /// + /// Sent to a window after it has gained the keyboard focus. + /// + WM_SETFOCUS = 0x0007, + + /// + /// An application sends the WM_COPYDATA message to pass data to another application. + /// + WM_COPYDATA = 0x004A, + + /// + /// Used to define private messages for use by private window classes, usually of the form WM_USER+x, where x is an integer value. + /// + WM_USER = 0x400, + } + + // http://www.pinvoke.net/default.aspx/Enums/ShowWindowCommand.html + public enum ShowWindowCommands + { + /// + /// Hides the window and activates another window. + /// + Hide = 0, + /// + /// Activates and displays a window. If the window is minimized or + /// maximized, the system restores it to its original size and position. + /// An application should specify this flag when displaying the window + /// for the first time. + /// + Normal = 1, + /// + /// Activates the window and displays it as a minimized window. + /// + ShowMinimized = 2, + /// + /// Maximizes the specified window. + /// + Maximize = 3, // is this the right value? + /// + /// Activates the window and displays it as a maximized window. + /// + ShowMaximized = 3, + /// + /// Displays a window in its most recent size and position. This value + /// is similar to , except + /// the window is not activated. + /// + ShowNoActivate = 4, + /// + /// Activates the window and displays it in its current size and position. + /// + Show = 5, + /// + /// Minimizes the specified window and activates the next top-level + /// window in the Z order. + /// + Minimize = 6, + /// + /// Displays the window as a minimized window. This value is similar to + /// , except the + /// window is not activated. + /// + ShowMinNoActive = 7, + /// + /// Displays the window in its current size and position. This value is + /// similar to , except the + /// window is not activated. + /// + ShowNA = 8, + /// + /// Activates and displays the window. If the window is minimized or + /// maximized, the system restores it to its original size and position. + /// An application should specify this flag when restoring a minimized window. + /// + Restore = 9, + /// + /// Sets the show state based on the SW_* value specified in the + /// STARTUPINFO structure passed to the CreateProcess function by the + /// program that started the application. + /// + ShowDefault = 10, + /// + /// Windows 2000/XP: Minimizes a window, even if the thread + /// that owns the window is not responding. This flag should only be + /// used when minimizing windows from a different thread. + /// + ForceMinimize = 11 + } } } diff --git a/SinglePass.WPF/Hotkeys/HotkeyDelegates.cs b/SinglePass.WPF/Hotkeys/HotkeyDelegates.cs index 7a4acd3..ae6a1e3 100644 --- a/SinglePass.WPF/Hotkeys/HotkeyDelegates.cs +++ b/SinglePass.WPF/Hotkeys/HotkeyDelegates.cs @@ -1,5 +1,5 @@ using SinglePass.WPF.Helpers; -using SinglePass.WPF.Views; +using SinglePass.WPF.Views.Windows; using System; using System.Linq; using System.Runtime.InteropServices; diff --git a/SinglePass.WPF/Services/SyncService.cs b/SinglePass.WPF/Services/SyncService.cs index 54c82b7..c67098a 100644 --- a/SinglePass.WPF/Services/SyncService.cs +++ b/SinglePass.WPF/Services/SyncService.cs @@ -128,7 +128,7 @@ public async Task Upload(CloudType cloudType) SyncStateChanged?.Invoke(SinglePass.Language.Properties.Resources.Uploading); // Additional lock to ensure file not used by other thread - using var locker = AsyncDuplicateLock.Lock(Constants.PasswordsFilePath); + using var locker = await AsyncDuplicateLock.LockAsync(Constants.PasswordsFilePath); using var fileStream = File.Open(Constants.PasswordsFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); // Ensure begining fileStream.Seek(0, SeekOrigin.Begin); diff --git a/SinglePass.WPF/Settings/AppSettingsService.cs b/SinglePass.WPF/Settings/AppSettingsService.cs index d8379b5..e5b2723 100644 --- a/SinglePass.WPF/Settings/AppSettingsService.cs +++ b/SinglePass.WPF/Settings/AppSettingsService.cs @@ -70,16 +70,13 @@ public AppSettingsService(ILogger logger) } } - public Task Save() + public async Task Save() { - return Task.Run(() => - { - // Use local lock instead of interprocess lock - only one instance of app will work with this file - using var locker = AsyncDuplicateLock.Lock(Constants.CommonSettingsFilePath); - using var fileStream = new FileStream(Constants.CommonSettingsFilePath, FileMode.Create, FileAccess.Write, FileShare.Read); - JsonSerializer.Serialize(fileStream, Settings); - _logger.LogInformation("Settings saved to file"); - }); + // Use local lock instead of interprocess lock - only one instance of app will work with this file + using var locker = await AsyncDuplicateLock.LockAsync(Constants.CommonSettingsFilePath).ConfigureAwait(false); + using var fileStream = new FileStream(Constants.CommonSettingsFilePath, FileMode.Create, FileAccess.Write, FileShare.Read); + await JsonSerializer.SerializeAsync(fileStream, Settings).ConfigureAwait(false); + _logger.LogInformation("Settings saved to file"); } } } diff --git a/SinglePass.WPF/SinglePass.WPF.csproj b/SinglePass.WPF/SinglePass.WPF.csproj index 6969315..ebc892f 100644 --- a/SinglePass.WPF/SinglePass.WPF.csproj +++ b/SinglePass.WPF/SinglePass.WPF.csproj @@ -26,7 +26,7 @@ - + @@ -54,15 +54,4 @@ - - - - - - - - - - - diff --git a/SinglePass.WPF/Themes/DialogHostTemplates.xaml b/SinglePass.WPF/Themes/DialogHostTemplates.xaml index 8eeb06c..3fedd89 100644 --- a/SinglePass.WPF/Themes/DialogHostTemplates.xaml +++ b/SinglePass.WPF/Themes/DialogHostTemplates.xaml @@ -5,7 +5,7 @@ - + IsHitTestVisible="False"> - + - \ No newline at end of file + diff --git a/SinglePass.WPF/ViewModels/CloudSyncViewModel.cs b/SinglePass.WPF/ViewModels/CloudSyncViewModel.cs index 2767398..ac6c0b8 100644 --- a/SinglePass.WPF/ViewModels/CloudSyncViewModel.cs +++ b/SinglePass.WPF/ViewModels/CloudSyncViewModel.cs @@ -7,9 +7,8 @@ using SinglePass.WPF.Helpers; using SinglePass.WPF.Services; using SinglePass.WPF.Settings; -using SinglePass.WPF.Views; -using SinglePass.WPF.Views.InputBox; -using SinglePass.WPF.Views.MessageBox; +using SinglePass.WPF.ViewModels.Dialogs; +using SinglePass.WPF.Views.Helpers; using System; using System.Threading; using System.Threading.Tasks; @@ -83,52 +82,55 @@ public CloudSyncViewModel( [RelayCommand] private async Task Login(CloudType cloudType) { - var windowDialogName = DialogIdentifiers.MainWindowName; - var authorizing = false; - switch (cloudType) + + try { - case CloudType.GoogleDrive: - authorizing = !GoogleDriveEnabled; - break; + // Authorize + var cloudService = _cloudServiceProvider.GetCloudService(cloudType); + _ = ProcessingDialog.Show( + SinglePass.Language.Properties.Resources.Authorizing, + SinglePass.Language.Properties.Resources.PleaseContinueAuthorizationOrCancelIt, + DialogIdentifiers.MainWindowName, + out CancellationToken cancellationToken); + + await cloudService.AuthorizationBroker.AuthorizeAsync(cancellationToken); + _logger.LogInformation($"Authorization process to {cloudType} has been complete."); + GoogleDriveEnabled = true; + _ = FetchUserInfoFromCloud(cloudType, CancellationToken.None); // Don't await set user info for now + await _appSettingsService.Save(); + } + catch (OperationCanceledException) + { + _logger.LogWarning($"Authorization process to {cloudType} has been cancelled."); + } + catch (Exception ex) + { + _logger.LogError(ex, string.Empty); + } + finally + { + if (DialogHost.IsDialogOpen(DialogIdentifiers.MainWindowName)) + DialogHost.Close(DialogIdentifiers.MainWindowName); } - var cloudService = _cloudServiceProvider.GetCloudService(cloudType); + } + [RelayCommand] + private async Task Logout(CloudType cloudType) + { try { - if (authorizing) - { - // Authorize - var processingControl = new ProcessingControl( - SinglePass.Language.Properties.Resources.Authorizing, - SinglePass.Language.Properties.Resources.PleaseContinueAuthorizationOrCancelIt, - windowDialogName); - var token = processingControl.ViewModel.CancellationToken; - _ = DialogHost.Show(processingControl, windowDialogName); // Don't await dialog host - - await cloudService.AuthorizationBroker.AuthorizeAsync(token); - _logger.LogInformation($"Authorization process to {cloudType} has been complete."); - GoogleDriveEnabled = true; - await _appSettingsService.Save(); - - _ = FetchUserInfoFromCloud(cloudType, CancellationToken.None); // Don't await set user info for now - } - else - { - // Revoke - var processingControl = new ProcessingControl( - SinglePass.Language.Properties.Resources.SigningOut, - SinglePass.Language.Properties.Resources.PleaseWait, - windowDialogName); - var token = processingControl.ViewModel.CancellationToken; - _ = DialogHost.Show(processingControl, windowDialogName); // Don't await dialog host - - await cloudService.AuthorizationBroker.RevokeToken(token); - - GoogleDriveEnabled = false; - await _appSettingsService.Save(); - - ClearUserInfo(cloudType); - } + // Revoke + var cloudService = _cloudServiceProvider.GetCloudService(cloudType); + _ = ProcessingDialog.Show( + SinglePass.Language.Properties.Resources.SigningOut, + SinglePass.Language.Properties.Resources.PleaseWait, + DialogIdentifiers.MainWindowName, + out CancellationToken cancellationToken); + + await cloudService.AuthorizationBroker.RevokeToken(cancellationToken); + GoogleDriveEnabled = false; + ClearUserInfo(cloudType); + await _appSettingsService.Save(); } catch (OperationCanceledException) { @@ -140,8 +142,8 @@ private async Task Login(CloudType cloudType) } finally { - if (DialogHost.IsDialogOpen(windowDialogName)) - DialogHost.Close(windowDialogName); + if (DialogHost.IsDialogOpen(DialogIdentifiers.MainWindowName)) + DialogHost.Close(DialogIdentifiers.MainWindowName); } } diff --git a/SinglePass.WPF/ViewModels/CredentialsDialogViewModel.cs b/SinglePass.WPF/ViewModels/CredentialsDetailsViewModel.cs similarity index 83% rename from SinglePass.WPF/ViewModels/CredentialsDialogViewModel.cs rename to SinglePass.WPF/ViewModels/CredentialsDetailsViewModel.cs index ed5f058..4b084cc 100644 --- a/SinglePass.WPF/ViewModels/CredentialsDialogViewModel.cs +++ b/SinglePass.WPF/ViewModels/CredentialsDetailsViewModel.cs @@ -10,28 +10,28 @@ namespace SinglePass.WPF.ViewModels { - public class CredentialsDialogViewModel : ObservableRecipient + public class CredentialsDetailsViewModel : ObservableRecipient { #region Design time instance - private static readonly Lazy _lazy = new(GetDesignTimeVM); - public static CredentialsDialogViewModel DesignTimeInstance => _lazy.Value; - private static CredentialsDialogViewModel GetDesignTimeVM() + private static readonly Lazy _lazy = new(GetDesignTimeVM); + public static CredentialsDetailsViewModel DesignTimeInstance => _lazy.Value; + private static CredentialsDetailsViewModel GetDesignTimeVM() { var additionalFields = new List() { new PassField() { Name = "Design additional field", Value = "Test value" } }; var model = Credential.CreateNew(); model.AdditionalFields = additionalFields; var credVm = new CredentialViewModel(model, null); - var vm = new CredentialsDialogViewModel(null); + var vm = new CredentialsDetailsViewModel(null); vm._credentialViewModel = credVm; - vm.Mode = CredentialsDialogMode.View; + vm.Mode = CredentialDetailsMode.View; return vm; } #endregion - private readonly ILogger _logger; + private readonly ILogger _logger; - public event Action Accept; + public event Action Accept; public event Action Delete; public event Action Cancel; public event Action EnqueueSnackbarMessage; @@ -43,7 +43,7 @@ private static CredentialsDialogViewModel GetDesignTimeVM() private RelayCommand _openInBrowserCommand; private RelayCommand _copyToClipboardCommand; private CredentialViewModel _credentialViewModel; - private CredentialsDialogMode _mode = CredentialsDialogMode.View; + private CredentialDetailsMode _mode = CredentialDetailsMode.View; private bool _isPasswordVisible; private bool _isNameTextBoxFocused; @@ -66,11 +66,11 @@ public string CaptionText { switch (_mode) { - case CredentialsDialogMode.Edit: + case CredentialDetailsMode.Edit: return SinglePass.Language.Properties.Resources.Edit; - case CredentialsDialogMode.New: + case CredentialDetailsMode.New: return SinglePass.Language.Properties.Resources.New; - case CredentialsDialogMode.View: + case CredentialDetailsMode.View: return SinglePass.Language.Properties.Resources.Details; default: break; @@ -80,7 +80,7 @@ public string CaptionText } } - public CredentialsDialogMode Mode + public CredentialDetailsMode Mode { get => _mode; set @@ -102,7 +102,7 @@ public bool IsNameTextBoxFocused set => SetProperty(ref _isNameTextBoxFocused, value); } - public CredentialsDialogViewModel(ILogger logger) + public CredentialsDetailsViewModel(ILogger logger) { _logger = logger; } @@ -127,7 +127,7 @@ private void EditExecute() return; CredentialViewModel = CredentialViewModel.Clone(); - Mode = CredentialsDialogMode.Edit; + Mode = CredentialDetailsMode.Edit; IsPasswordVisible = true; SetFocus(); } diff --git a/SinglePass.WPF/Views/InputBox/MaterialInputBoxViewModel.cs b/SinglePass.WPF/ViewModels/Dialogs/MaterialInputBoxViewModel.cs similarity index 98% rename from SinglePass.WPF/Views/InputBox/MaterialInputBoxViewModel.cs rename to SinglePass.WPF/ViewModels/Dialogs/MaterialInputBoxViewModel.cs index 51df962..75f2ea4 100644 --- a/SinglePass.WPF/Views/InputBox/MaterialInputBoxViewModel.cs +++ b/SinglePass.WPF/ViewModels/Dialogs/MaterialInputBoxViewModel.cs @@ -3,7 +3,7 @@ using MaterialDesignThemes.Wpf; using System; -namespace SinglePass.WPF.Views.InputBox +namespace SinglePass.WPF.ViewModels.Dialogs { internal class MaterialInputBoxViewModel : ObservableObject { diff --git a/SinglePass.WPF/Views/MessageBox/MaterialMessageBoxViewModel.cs b/SinglePass.WPF/ViewModels/Dialogs/MaterialMessageBoxViewModel.cs similarity index 83% rename from SinglePass.WPF/Views/MessageBox/MaterialMessageBoxViewModel.cs rename to SinglePass.WPF/ViewModels/Dialogs/MaterialMessageBoxViewModel.cs index 202411a..b43d1c4 100644 --- a/SinglePass.WPF/Views/MessageBox/MaterialMessageBoxViewModel.cs +++ b/SinglePass.WPF/ViewModels/Dialogs/MaterialMessageBoxViewModel.cs @@ -4,8 +4,84 @@ using System; using System.Windows; -namespace SinglePass.WPF.Views.MessageBox +namespace SinglePass.WPF.ViewModels.Dialogs { + public enum MaterialMessageBoxButtons + { + /// + /// The message box contains an OK button. + /// + OK, + + /// + /// The message box contains OK and Cancel buttons. + /// + OKCancel, + + /// + /// The message box contains Yes and No buttons. + /// + YesNo, + + /// + /// The message box contains Yes, No, and Cancel buttons. + /// + YesNoCancel, + + /// + /// The message box contains Abort, Retry, and Ignore buttons. + /// + AbortRetryIgnore, + + /// + /// The message box contains Retry and Cancel buttons. + /// + RetryCancel + } + + public enum MaterialDialogResult + { + /// + /// Nothing is returned from the dialog box. This means that the modal dialog continues running. + /// + None = 0, + + /// + /// The dialog box return value is OK (usually sent from a button labeled OK). + /// + OK = 1, + + /// + /// The dialog box return value is Cancel (usually sent from a button labeled Cancel). + /// + Cancel = 2, + + /// + /// The dialog box return value is Abort (usually sent from a button labeled Abort). + /// + Abort = 3, + + /// + /// The dialog box return value is Retry (usually sent from a button labeled Retry). + /// + Retry = 4, + + /// + /// The dialog box return value is Ignore (usually sent from a button labeled Ignore). + /// + Ignore = 5, + + /// + /// The dialog box return value is Yes (usually sent from a button labeled Yes). + /// + Yes = 6, + + /// + /// The dialog box return value is No (usually sent from a button labeled No). + /// + No = 7 + } + internal class MaterialMessageBoxViewModel : ObservableRecipient { #region Design time instance diff --git a/SinglePass.WPF/ViewModels/ProcessingViewModel.cs b/SinglePass.WPF/ViewModels/Dialogs/ProcessingViewModel.cs similarity index 97% rename from SinglePass.WPF/ViewModels/ProcessingViewModel.cs rename to SinglePass.WPF/ViewModels/Dialogs/ProcessingViewModel.cs index 8187d88..d250f6d 100644 --- a/SinglePass.WPF/ViewModels/ProcessingViewModel.cs +++ b/SinglePass.WPF/ViewModels/Dialogs/ProcessingViewModel.cs @@ -4,7 +4,7 @@ using System; using System.Threading; -namespace SinglePass.WPF.ViewModels +namespace SinglePass.WPF.ViewModels.Dialogs { [INotifyPropertyChanged] public partial class ProcessingViewModel diff --git a/SinglePass.WPF/ViewModels/MainWindowViewModel.cs b/SinglePass.WPF/ViewModels/MainWindowViewModel.cs index aa50534..e7d50dc 100644 --- a/SinglePass.WPF/ViewModels/MainWindowViewModel.cs +++ b/SinglePass.WPF/ViewModels/MainWindowViewModel.cs @@ -3,6 +3,7 @@ using MaterialDesignThemes.Wpf; using SinglePass.WPF.Collections; using SinglePass.WPF.Views; +using SinglePass.WPF.Views.Controls; using System; using System.Collections.Generic; using System.Linq; @@ -25,6 +26,7 @@ private static MainWindowViewModel GetDesignTimeVM() #endregion public event Action CredentialSelected; + public event Action ScrollIntoViewRequired; public PasswordsViewModel PasswordsVM { get; } public SettingsViewModel SettingsVM { get; } @@ -57,6 +59,7 @@ public MainWindowViewModel( SettingsVM = settingsViewModel; PasswordsVM.CredentialSelected += PasswordsViewModel_CredentialSelected; + PasswordsVM.ScrollIntoViewRequired += PasswordsVM_ScrollIntoViewRequired; CloudSyncVM.SyncCompleted += SettingsViewModel_SyncCompleted; NavigationItems = new ObservableCollectionDelayed(new List() @@ -76,6 +79,11 @@ public MainWindowViewModel( }); } + private void PasswordsVM_ScrollIntoViewRequired(CredentialViewModel credVM) + { + ScrollIntoViewRequired?.Invoke(credVM); + } + private void SettingsViewModel_SyncCompleted() { PasswordsVM.ReloadCredentials(); diff --git a/SinglePass.WPF/ViewModels/PasswordsViewModel.cs b/SinglePass.WPF/ViewModels/PasswordsViewModel.cs index ff235c4..c0671b8 100644 --- a/SinglePass.WPF/ViewModels/PasswordsViewModel.cs +++ b/SinglePass.WPF/ViewModels/PasswordsViewModel.cs @@ -8,7 +8,8 @@ using SinglePass.WPF.Models; using SinglePass.WPF.Services; using SinglePass.WPF.Settings; -using SinglePass.WPF.Views.MessageBox; +using SinglePass.WPF.ViewModels.Dialogs; +using SinglePass.WPF.Views.Helpers; using System; using System.Collections.Generic; using System.Linq; @@ -46,9 +47,10 @@ private static PasswordsViewModel GetDesignTimeVM() private readonly CredentialViewModelFactory _credentialViewModelFactory; public event Action CredentialSelected; + public event Action ScrollIntoViewRequired; public ObservableCollectionDelayed DisplayedCredentials { get; private set; } = new(); - public CredentialsDialogViewModel ActiveCredentialDialogVM { get; } + public CredentialsDetailsViewModel ActiveCredentialDialogVM { get; } private CredentialViewModel _selectedCredential; public CredentialViewModel SelectedCredential @@ -57,7 +59,7 @@ public CredentialViewModel SelectedCredential set { SetProperty(ref _selectedCredential, value); - ActiveCredentialDialogVM.Mode = CredentialsDialogMode.View; + ActiveCredentialDialogVM.Mode = CredentialDetailsMode.View; ActiveCredentialDialogVM.CredentialViewModel = value; ActiveCredentialDialogVM.IsPasswordVisible = false; CredentialSelected?.Invoke(value); @@ -102,7 +104,7 @@ private PasswordsViewModel() { } public PasswordsViewModel( CredentialsCryptoService credentialsCryptoService, ILogger logger, - CredentialsDialogViewModel credentialsDialogViewModel, + CredentialsDetailsViewModel credentialsDialogViewModel, AppSettingsService appSettingsService, CredentialViewModelFactory credentialViewModelFactory) { @@ -146,22 +148,22 @@ private async void ActiveCredentialDialogVM_Delete(CredentialViewModel credVM) private void ActiveCredentialDialogVM_Cancel() { ActiveCredentialDialogVM.IsPasswordVisible = false; - ActiveCredentialDialogVM.Mode = CredentialsDialogMode.View; + ActiveCredentialDialogVM.Mode = CredentialDetailsMode.View; ActiveCredentialDialogVM.CredentialViewModel = SelectedCredential; } - private async void ActiveCredentialDialogVM_Accept(CredentialViewModel newCredVM, CredentialsDialogMode mode) + private async void ActiveCredentialDialogVM_Accept(CredentialViewModel newCredVM, CredentialDetailsMode mode) { var dateTimeNow = DateTime.Now; newCredVM.LastModifiedTime = dateTimeNow; - if (mode == CredentialsDialogMode.New) + if (mode == CredentialDetailsMode.New) { newCredVM.CreationTime = dateTimeNow; await _credentialsCryptoService.AddCredential(newCredVM.Model); _credentialVMs.Add(newCredVM); await DisplayCredentialsAsync(); } - else if (mode == CredentialsDialogMode.Edit) + else if (mode == CredentialDetailsMode.Edit) { await _credentialsCryptoService.EditCredential(newCredVM.Model); var staleCredVM = _credentialVMs.FirstOrDefault(c => c.Model.Equals(newCredVM.Model)); @@ -257,7 +259,7 @@ await Task.Run(() => private void AddCredential() { ActiveCredentialDialogVM.CredentialViewModel = _credentialViewModelFactory.ProvideNew(Credential.CreateNew()); - ActiveCredentialDialogVM.Mode = CredentialsDialogMode.New; + ActiveCredentialDialogVM.Mode = CredentialDetailsMode.New; ActiveCredentialDialogVM.IsPasswordVisible = true; ActiveCredentialDialogVM.SetFocus(); } @@ -268,24 +270,30 @@ private void HandleSearchKey(KeyEventArgs args) if (args is null) return; - if (args.Key == Key.Up) + switch (args.Key) { - // Select previous - var selectedIndex = DisplayedCredentials.IndexOf(SelectedCredential); - if (selectedIndex != -1 && selectedIndex > 0) - { - SelectedCredential = DisplayedCredentials[selectedIndex - 1]; - } - } - - if (args.Key == Key.Down) - { - // Select next - var selectedIndex = DisplayedCredentials.IndexOf(SelectedCredential); - if (selectedIndex != -1 && selectedIndex < DisplayedCredentials.Count - 1) - { - SelectedCredential = DisplayedCredentials[selectedIndex + 1]; - } + case Key.Up: + { + // Select previous + var selectedIndex = DisplayedCredentials.IndexOf(SelectedCredential); + if (selectedIndex != -1 && selectedIndex > 0) + { + SelectedCredential = DisplayedCredentials[selectedIndex - 1]; + ScrollIntoViewRequired?.Invoke(SelectedCredential); + } + break; + } + case Key.Down: + { + // Select next + var selectedIndex = DisplayedCredentials.IndexOf(SelectedCredential); + if (selectedIndex != -1 && selectedIndex < DisplayedCredentials.Count - 1) + { + SelectedCredential = DisplayedCredentials[selectedIndex + 1]; + ScrollIntoViewRequired?.Invoke(SelectedCredential); + } + break; + } } } diff --git a/SinglePass.WPF/ViewModels/PopupViewModel.cs b/SinglePass.WPF/ViewModels/PopupViewModel.cs index dc61a49..c0a311a 100644 --- a/SinglePass.WPF/ViewModels/PopupViewModel.cs +++ b/SinglePass.WPF/ViewModels/PopupViewModel.cs @@ -34,6 +34,7 @@ private static PopupViewModel GetDesignTimeVM() private readonly List _credentialVMs = new(); public event Action Accept; + public event Action ScrollIntoViewRequired; public ObservableCollectionDelayed DisplayedCredentials { get; private set; } = new(); @@ -240,6 +241,7 @@ private void HandleSearchKey(KeyEventArgs args) if (selectedIndex != -1 && selectedIndex > 0) { SelectedCredentialVM = DisplayedCredentials[selectedIndex - 1]; + ScrollIntoViewRequired?.Invoke(SelectedCredentialVM); } break; } @@ -250,6 +252,7 @@ private void HandleSearchKey(KeyEventArgs args) if (selectedIndex != -1 && selectedIndex < DisplayedCredentials.Count - 1) { SelectedCredentialVM = DisplayedCredentials[selectedIndex + 1]; + ScrollIntoViewRequired?.Invoke(SelectedCredentialVM); } break; } diff --git a/SinglePass.WPF/ViewModels/SettingsViewModel.cs b/SinglePass.WPF/ViewModels/SettingsViewModel.cs index 1b4f281..7a862ac 100644 --- a/SinglePass.WPF/ViewModels/SettingsViewModel.cs +++ b/SinglePass.WPF/ViewModels/SettingsViewModel.cs @@ -6,7 +6,8 @@ using SinglePass.WPF.Hotkeys; using SinglePass.WPF.Services; using SinglePass.WPF.Settings; -using SinglePass.WPF.Views.MessageBox; +using SinglePass.WPF.ViewModels.Dialogs; +using SinglePass.WPF.Views.Helpers; using System; using System.Threading.Tasks; diff --git a/SinglePass.WPF/ViewModels/TrayIconViewModel.cs b/SinglePass.WPF/ViewModels/TrayIconViewModel.cs index 2c4afe9..012515f 100644 --- a/SinglePass.WPF/ViewModels/TrayIconViewModel.cs +++ b/SinglePass.WPF/ViewModels/TrayIconViewModel.cs @@ -1,7 +1,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using SinglePass.WPF.Extensions; -using SinglePass.WPF.Views; +using SinglePass.WPF.Views.Windows; using System.Linq; namespace SinglePass.WPF.ViewModels diff --git a/SinglePass.WPF/Views/CloudSyncControl.xaml b/SinglePass.WPF/Views/Controls/CloudSyncControl.xaml similarity index 98% rename from SinglePass.WPF/Views/CloudSyncControl.xaml rename to SinglePass.WPF/Views/Controls/CloudSyncControl.xaml index b7c03d7..f9ed07d 100644 --- a/SinglePass.WPF/Views/CloudSyncControl.xaml +++ b/SinglePass.WPF/Views/Controls/CloudSyncControl.xaml @@ -1,5 +1,5 @@