diff --git a/Git-Credential-Manager.sln b/Git-Credential-Manager.sln index 9b0e76ba8..39248b52c 100644 --- a/Git-Credential-Manager.sln +++ b/Git-Credential-Manager.sln @@ -67,6 +67,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitLab.UI.Avalonia", "src\s EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitLab.UI.Windows", "src\windows\GitLab.UI.Windows\GitLab.UI.Windows.csproj", "{83EAC1F9-8E1F-41FC-8FC9-2C452452D64E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Git-Credential-Manager.UI.Avalonia", "src\shared\Git-Credential-Manager.UI.Avalonia\Git-Credential-Manager.UI.Avalonia.csproj", "{35659127-8859-4DB9-8DD6-A08C1952632E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Git-Credential-Manager.UI.Windows", "src\windows\Git-Credential-Manager.UI.Windows\Git-Credential-Manager.UI.Windows.csproj", "{01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -469,6 +473,32 @@ Global {83EAC1F9-8E1F-41FC-8FC9-2C452452D64E}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU {83EAC1F9-8E1F-41FC-8FC9-2C452452D64E}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU {83EAC1F9-8E1F-41FC-8FC9-2C452452D64E}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {35659127-8859-4DB9-8DD6-A08C1952632E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {35659127-8859-4DB9-8DD6-A08C1952632E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {35659127-8859-4DB9-8DD6-A08C1952632E}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU + {35659127-8859-4DB9-8DD6-A08C1952632E}.MacDebug|Any CPU.Build.0 = Debug|Any CPU + {35659127-8859-4DB9-8DD6-A08C1952632E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {35659127-8859-4DB9-8DD6-A08C1952632E}.Release|Any CPU.Build.0 = Release|Any CPU + {35659127-8859-4DB9-8DD6-A08C1952632E}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU + {35659127-8859-4DB9-8DD6-A08C1952632E}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU + {35659127-8859-4DB9-8DD6-A08C1952632E}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {35659127-8859-4DB9-8DD6-A08C1952632E}.LinuxDebug|Any CPU.Build.0 = Debug|Any CPU + {35659127-8859-4DB9-8DD6-A08C1952632E}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU + {35659127-8859-4DB9-8DD6-A08C1952632E}.LinuxRelease|Any CPU.Build.0 = Release|Any CPU + {35659127-8859-4DB9-8DD6-A08C1952632E}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU + {35659127-8859-4DB9-8DD6-A08C1952632E}.MacRelease|Any CPU.Build.0 = Release|Any CPU + {35659127-8859-4DB9-8DD6-A08C1952632E}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU + {35659127-8859-4DB9-8DD6-A08C1952632E}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU + {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU + {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU + {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU + {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.WindowsRelease|Any CPU.ActiveCfg = Debug|Any CPU + {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -505,6 +535,8 @@ Global {9AFD88E2-7E2C-46DA-9D38-4342086426D3} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29} {47186A50-8889-4FC7-8A05-F9FCE7F8F4AE} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29} {83EAC1F9-8E1F-41FC-8FC9-2C452452D64E} = {66722747-1B61-40E4-A89B-1AC8E6D62EA9} + {35659127-8859-4DB9-8DD6-A08C1952632E} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29} + {01BF56EC-AAC1-4BCA-8204-EE51D968DF5C} = {66722747-1B61-40E4-A89B-1AC8E6D62EA9} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0EF9FC65-E6BA-45D4-A455-262A9EA4366B} diff --git a/src/linux/Packaging.Linux/build.sh b/src/linux/Packaging.Linux/build.sh index a9430850d..7509be929 100755 --- a/src/linux/Packaging.Linux/build.sh +++ b/src/linux/Packaging.Linux/build.sh @@ -47,6 +47,7 @@ ROOT="$( cd "$THISDIR"/../../.. ; pwd -P )" SRC="$ROOT/src" OUT="$ROOT/out" GCM_SRC="$SRC/shared/Git-Credential-Manager" +GCM_UI_SRC="$SRC/shared/Git-Credential-Manager.UI.Avalonia" BITBUCKET_UI_SRC="$SRC/shared/Atlassian.Bitbucket.UI.Avalonia" GITHUB_UI_SRC="$SRC/shared/GitHub.UI.Avalonia" GITLAB_UI_SRC="$SRC/shared/GitLab.UI.Avalonia" @@ -120,6 +121,15 @@ $DOTNET_ROOT/dotnet publish "$GCM_SRC" \ -p:PublishSingleFile=true \ --output="$(make_absolute "$PAYLOAD")" || exit 1 +echo "Publishing core UI helper..." +$DOTNET_ROOT/dotnet publish "$GCM_UI_SRC" \ + --configuration="$CONFIGURATION" \ + --framework="$FRAMEWORK" \ + --runtime="$RUNTIME" \ + --self-contained=true \ + -p:PublishSingleFile=true \ + --output="$(make_absolute "$PAYLOAD")" || exit 1 + echo "Publishing Bitbucket UI helper..." $DOTNET_ROOT/dotnet publish "$BITBUCKET_UI_SRC" \ --configuration="$CONFIGURATION" \ diff --git a/src/osx/Installer.Mac/layout.sh b/src/osx/Installer.Mac/layout.sh index b9991713d..179f1f0be 100755 --- a/src/osx/Installer.Mac/layout.sh +++ b/src/osx/Installer.Mac/layout.sh @@ -21,6 +21,7 @@ SRC="$ROOT/src" OUT="$ROOT/out" INSTALLER_SRC="$SRC/osx/Installer.Mac" GCM_SRC="$SRC/shared/Git-Credential-Manager" +GCM_UI_SRC="$SRC/shared/Git-Credential-Manager.UI.Avalonia" BITBUCKET_UI_SRC="$SRC/shared/Atlassian.Bitbucket.UI.Avalonia" GITHUB_UI_SRC="$SRC/shared/GitHub.UI.Avalonia" GITLAB_UI_SRC="$SRC/shared/GitLab.UI.Avalonia" @@ -104,6 +105,16 @@ dotnet publish "$GCM_SRC" \ --self-contained \ --output="$(make_absolute "$PAYLOAD")" || exit 1 +echo "Publishing core UI helper..." +dotnet publish "$GCM_UI_SRC" \ + --no-restore \ + -m:1 \ + --configuration="$CONFIGURATION" \ + --framework="$FRAMEWORK" \ + --runtime="$RUNTIME" \ + --self-contained \ + --output="$(make_absolute "$PAYLOAD")" || exit 1 + echo "Publishing Bitbucket UI helper..." dotnet publish "$BITBUCKET_UI_SRC" \ --no-restore \ diff --git a/src/shared/Core.Tests/Authentication/BasicAuthenticationTests.cs b/src/shared/Core.Tests/Authentication/BasicAuthenticationTests.cs index a991108f4..7539a37c1 100644 --- a/src/shared/Core.Tests/Authentication/BasicAuthenticationTests.cs +++ b/src/shared/Core.Tests/Authentication/BasicAuthenticationTests.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Threading.Tasks; using GitCredentialManager.Authentication; using GitCredentialManager.Tests.Objects; using Moq; @@ -14,11 +16,11 @@ public void BasicAuthentication_GetCredentials_NullResource_ThrowsException() var context = new TestCommandContext(); var basicAuth = new BasicAuthentication(context); - Assert.Throws(() => basicAuth.GetCredentials(null)); + Assert.ThrowsAsync(() => basicAuth.GetCredentialsAsync(null)); } [Fact] - public void BasicAuthentication_GetCredentials_NonDesktopSession_ResourceAndUserName_PasswordPromptReturnsCredentials() + public async Task BasicAuthentication_GetCredentials_NonDesktopSession_ResourceAndUserName_PasswordPromptReturnsCredentials() { const string testResource = "https://example.com"; const string testUserName = "john.doe"; @@ -29,14 +31,14 @@ public void BasicAuthentication_GetCredentials_NonDesktopSession_ResourceAndUser var basicAuth = new BasicAuthentication(context); - ICredential credential = basicAuth.GetCredentials(testResource, testUserName); + ICredential credential = await basicAuth.GetCredentialsAsync(testResource, testUserName); Assert.Equal(testUserName, credential.Account); Assert.Equal(testPassword, credential.Password); } [Fact] - public void BasicAuthentication_GetCredentials_NonDesktopSession_Resource_UserPassPromptReturnsCredentials() + public async Task BasicAuthentication_GetCredentials_NonDesktopSession_Resource_UserPassPromptReturnsCredentials() { const string testResource = "https://example.com"; const string testUserName = "john.doe"; @@ -48,7 +50,7 @@ public void BasicAuthentication_GetCredentials_NonDesktopSession_Resource_UserPa var basicAuth = new BasicAuthentication(context); - ICredential credential = basicAuth.GetCredentials(testResource); + ICredential credential = await basicAuth.GetCredentialsAsync(testResource); Assert.Equal(testUserName, credential.Account); Assert.Equal(testPassword, credential.Password); @@ -67,11 +69,11 @@ public void BasicAuthentication_GetCredentials_NonDesktopSession_NoTerminalPromp var basicAuth = new BasicAuthentication(context); - Assert.Throws(() => basicAuth.GetCredentials(testResource)); + Assert.ThrowsAsync(() => basicAuth.GetCredentialsAsync(testResource)); } - [PlatformFact(Platforms.Windows)] - public void BasicAuthentication_GetCredentials_DesktopSession_Resource_UserPassPromptReturnsCredentials() + [Fact] + public async Task BasicAuthentication_GetCredentials_DesktopSession_CallsHelper() { const string testResource = "https://example.com"; const string testUserName = "john.doe"; @@ -79,88 +81,66 @@ public void BasicAuthentication_GetCredentials_DesktopSession_Resource_UserPassP var context = new TestCommandContext { - SessionManager = {IsDesktopSession = true}, - SystemPrompts = - { - CredentialPrompt = (resource, userName) => - { - Assert.Equal(testResource, resource); - Assert.Null(userName); - - return new GitCredential(testUserName, testPassword); - } - } + SessionManager = {IsDesktopSession = true} }; - var basicAuth = new BasicAuthentication(context); - - ICredential credential = basicAuth.GetCredentials(testResource); - - Assert.NotNull(credential); - Assert.Equal(testUserName, credential.Account); - Assert.Equal(testPassword, credential.Password); - } - - [PlatformFact(Platforms.Windows)] - public void BasicAuthentication_GetCredentials_DesktopSession_ResourceAndUser_PassPromptReturnsCredentials() - { - const string testResource = "https://example.com"; - const string testUserName = "john.doe"; - const string testPassword = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] - - var context = new TestCommandContext - { - SessionManager = {IsDesktopSession = true}, - SystemPrompts = - { - CredentialPrompt = (resource, userName) => + context.FileSystem.Files["/usr/local/bin/git-credential-manager-ui"] = new byte[0]; + context.FileSystem.Files[@"C:\Program Files\Git Credential Manager Core\git-credential-manager-ui.exe"] = new byte[0]; + + var auth = new Mock(MockBehavior.Strict, context); + auth.Setup(x => x.InvokeHelperAsync( + It.IsAny(), + $"basic --resource {testResource}", + It.IsAny>(), + It.IsAny())) + .ReturnsAsync( + new Dictionary { - Assert.Equal(testResource, resource); - Assert.Equal(testUserName, userName); - - return new GitCredential(testUserName, testPassword); + ["username"] = testUserName, + ["password"] = testPassword } - } - }; - - var basicAuth = new BasicAuthentication(context); + ); - ICredential credential = basicAuth.GetCredentials(testResource, testUserName); + ICredential credential = await auth.Object.GetCredentialsAsync(testResource); Assert.NotNull(credential); Assert.Equal(testUserName, credential.Account); Assert.Equal(testPassword, credential.Password); } - [PlatformFact(Platforms.Windows)] - public void BasicAuthentication_GetCredentials_DesktopSession_ResourceAndUser_PassPromptDiffUserReturnsCredentials() + [Fact] + public async Task BasicAuthentication_GetCredentials_DesktopSession_UserName_CallsHelper() { const string testResource = "https://example.com"; const string testUserName = "john.doe"; - const string newUserName = "jane.doe"; const string testPassword = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] var context = new TestCommandContext { - SessionManager = {IsDesktopSession = true}, - SystemPrompts = - { - CredentialPrompt = (resource, userName) => - { - Assert.Equal(testResource, resource); - Assert.Equal(testUserName, userName); - - return new GitCredential(newUserName, testPassword); - } - } + SessionManager = {IsDesktopSession = true} }; - var basicAuth = new BasicAuthentication(context); + context.FileSystem.Files["/usr/local/bin/git-credential-manager-ui"] = new byte[0]; + context.FileSystem.Files[@"C:\Program Files\Git Credential Manager Core\git-credential-manager-ui.exe"] = new byte[0]; + + var auth = new Mock(MockBehavior.Strict, context); + auth.Setup(x => x.InvokeHelperAsync( + It.IsAny(), + $"basic --resource {testResource} --username {testUserName}", + It.IsAny>(), + It.IsAny())) + .ReturnsAsync( + new Dictionary + { + ["username"] = testUserName, + ["password"] = testPassword + } + ); - ICredential credential = basicAuth.GetCredentials(testResource, testUserName); + ICredential credential = await auth.Object.GetCredentialsAsync(testResource, testUserName); Assert.NotNull(credential); - Assert.Equal(newUserName, credential.Account); + Assert.Equal(testUserName, credential.Account); Assert.Equal(testPassword, credential.Password); } } diff --git a/src/shared/Core.Tests/GenericHostProviderTests.cs b/src/shared/Core.Tests/GenericHostProviderTests.cs index d118ff57e..42dc62177 100644 --- a/src/shared/Core.Tests/GenericHostProviderTests.cs +++ b/src/shared/Core.Tests/GenericHostProviderTests.cs @@ -83,8 +83,8 @@ public async Task GenericHostProvider_CreateCredentialAsync_WiaNotAllowed_Return Settings = {IsWindowsIntegratedAuthenticationEnabled = false} }; var basicAuthMock = new Mock(); - basicAuthMock.Setup(x => x.GetCredentials(It.IsAny(), It.IsAny())) - .Returns(basicCredential) + basicAuthMock.Setup(x => x.GetCredentialsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(basicCredential) .Verifiable(); var wiaAuthMock = new Mock(); @@ -96,7 +96,7 @@ public async Task GenericHostProvider_CreateCredentialAsync_WiaNotAllowed_Return Assert.Equal(testUserName, credential.Account); Assert.Equal(testPassword, credential.Password); wiaAuthMock.Verify(x => x.GetIsSupportedAsync(It.IsAny()), Times.Never); - basicAuthMock.Verify(x => x.GetCredentials(It.IsAny(), It.IsAny()), Times.Once); + basicAuthMock.Verify(x => x.GetCredentialsAsync(It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -117,8 +117,8 @@ public async Task GenericHostProvider_CreateCredentialAsync_LegacyAuthorityBasic Settings = {LegacyAuthorityOverride = "basic"} }; var basicAuthMock = new Mock(); - basicAuthMock.Setup(x => x.GetCredentials(It.IsAny(), It.IsAny())) - .Returns(basicCredential) + basicAuthMock.Setup(x => x.GetCredentialsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(basicCredential) .Verifiable(); var wiaAuthMock = new Mock(); @@ -130,7 +130,7 @@ public async Task GenericHostProvider_CreateCredentialAsync_LegacyAuthorityBasic Assert.Equal(testUserName, credential.Account); Assert.Equal(testPassword, credential.Password); wiaAuthMock.Verify(x => x.GetIsSupportedAsync(It.IsAny()), Times.Never); - basicAuthMock.Verify(x => x.GetCredentials(It.IsAny(), It.IsAny()), Times.Once); + basicAuthMock.Verify(x => x.GetCredentialsAsync(It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -148,8 +148,8 @@ public async Task GenericHostProvider_CreateCredentialAsync_NonHttpProtocol_Retu var context = new TestCommandContext(); var basicAuthMock = new Mock(); - basicAuthMock.Setup(x => x.GetCredentials(It.IsAny(), It.IsAny())) - .Returns(basicCredential) + basicAuthMock.Setup(x => x.GetCredentialsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(basicCredential) .Verifiable(); var wiaAuthMock = new Mock(); @@ -161,7 +161,7 @@ public async Task GenericHostProvider_CreateCredentialAsync_NonHttpProtocol_Retu Assert.Equal(testUserName, credential.Account); Assert.Equal(testPassword, credential.Password); wiaAuthMock.Verify(x => x.GetIsSupportedAsync(It.IsAny()), Times.Never); - basicAuthMock.Verify(x => x.GetCredentials(It.IsAny(), It.IsAny()), Times.Once); + basicAuthMock.Verify(x => x.GetCredentialsAsync(It.IsAny(), It.IsAny()), Times.Once); } [PlatformFact(Platforms.Posix)] @@ -194,7 +194,7 @@ private static async Task TestCreateCredentialAsync_ReturnsEmptyCredential(bool var context = new TestCommandContext(); var basicAuthMock = new Mock(); - basicAuthMock.Setup(x => x.GetCredentials(It.IsAny(), It.IsAny())) + basicAuthMock.Setup(x => x.GetCredentialsAsync(It.IsAny(), It.IsAny())) .Verifiable(); var wiaAuthMock = new Mock(); wiaAuthMock.Setup(x => x.GetIsSupportedAsync(It.IsAny())) @@ -207,7 +207,7 @@ private static async Task TestCreateCredentialAsync_ReturnsEmptyCredential(bool Assert.NotNull(credential); Assert.Equal(string.Empty, credential.Account); Assert.Equal(string.Empty, credential.Password); - basicAuthMock.Verify(x => x.GetCredentials(It.IsAny(), It.IsAny()), Times.Never); + basicAuthMock.Verify(x => x.GetCredentialsAsync(It.IsAny(), It.IsAny()), Times.Never); } private static async Task TestCreateCredentialAsync_ReturnsBasicCredential(bool wiaSupported) @@ -224,8 +224,8 @@ private static async Task TestCreateCredentialAsync_ReturnsBasicCredential(bool var context = new TestCommandContext(); var basicAuthMock = new Mock(); - basicAuthMock.Setup(x => x.GetCredentials(It.IsAny(), It.IsAny())) - .Returns(basicCredential) + basicAuthMock.Setup(x => x.GetCredentialsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(basicCredential) .Verifiable(); var wiaAuthMock = new Mock(); wiaAuthMock.Setup(x => x.GetIsSupportedAsync(It.IsAny())) @@ -238,7 +238,7 @@ private static async Task TestCreateCredentialAsync_ReturnsBasicCredential(bool Assert.NotNull(credential); Assert.Equal(testUserName, credential.Account); Assert.Equal(testPassword, credential.Password); - basicAuthMock.Verify(x => x.GetCredentials(It.IsAny(), It.IsAny()), Times.Once); + basicAuthMock.Verify(x => x.GetCredentialsAsync(It.IsAny(), It.IsAny()), Times.Once); } #endregion diff --git a/src/shared/Core.UI.Avalonia/AvaloniaApp.axaml b/src/shared/Core.UI.Avalonia/AvaloniaApp.axaml index 05044ae2d..33e285d77 100644 --- a/src/shared/Core.UI.Avalonia/AvaloniaApp.axaml +++ b/src/shared/Core.UI.Avalonia/AvaloniaApp.axaml @@ -10,6 +10,7 @@ + diff --git a/src/shared/Core.UI.Avalonia/AvaloniaUi.cs b/src/shared/Core.UI.Avalonia/AvaloniaUi.cs index a7e698f58..0b0bea8fb 100644 --- a/src/shared/Core.UI.Avalonia/AvaloniaUi.cs +++ b/src/shared/Core.UI.Avalonia/AvaloniaUi.cs @@ -88,6 +88,15 @@ private static Task ShowWindowInternal(Func windowFunc, object dataConte window.Activate(); window.Focus(); + // Workaround an issue where "Activate()" and "Focus()" don't actually + // cause the window to become the top-most window. Avalonia is correctly + // calling 'makeKeyAndOrderFront' but this isn't working for some reason. + if (PlatformUtils.IsMacOS()) + { + window.Topmost = true; + window.Topmost = false; + } + return tcs.Task; } diff --git a/src/shared/Core.UI.Avalonia/Controls/AboutWindow.axaml b/src/shared/Core.UI.Avalonia/Controls/AboutWindow.axaml index 1164a06d4..e05c0251f 100644 --- a/src/shared/Core.UI.Avalonia/Controls/AboutWindow.axaml +++ b/src/shared/Core.UI.Avalonia/Controls/AboutWindow.axaml @@ -7,13 +7,6 @@ Title="About Git Credential Manager" CanResize="False" Width="300" SizeToContent="Height" Background="#F6F6F6"> - - - - - - - @@ -37,6 +30,6 @@ + Text="Copyright © GitHub"/> diff --git a/src/shared/Core.UI.Avalonia/Controls/DialogWindow.axaml b/src/shared/Core.UI.Avalonia/Controls/DialogWindow.axaml index 3ca500e90..f995a8790 100644 --- a/src/shared/Core.UI.Avalonia/Controls/DialogWindow.axaml +++ b/src/shared/Core.UI.Avalonia/Controls/DialogWindow.axaml @@ -10,7 +10,7 @@ ExtendClientAreaChromeHints="{Binding ShowCustomChrome, Converter={x:Static converters:WindowClientAreaConverters.BoolToChromeHints}}" Title="{Binding Title}" SizeToContent="Height" CanResize="False" - Width="420" MaxHeight="520" MinHeight="320" + Width="420" MaxHeight="520" MinHeight="280" WindowState="Normal" WindowStartupLocation="CenterScreen" ShowInTaskbar="True" ShowActivated="True" PointerPressed="Window_PointerPressed" diff --git a/src/shared/Core.UI.Avalonia/Core.UI.Avalonia.csproj b/src/shared/Core.UI.Avalonia/Core.UI.Avalonia.csproj index a7ade3aaa..961f67649 100644 --- a/src/shared/Core.UI.Avalonia/Core.UI.Avalonia.csproj +++ b/src/shared/Core.UI.Avalonia/Core.UI.Avalonia.csproj @@ -11,9 +11,9 @@ - - - + + + diff --git a/src/shared/Core.UI/Commands/CredentialsCommand.cs b/src/shared/Core.UI/Commands/CredentialsCommand.cs new file mode 100644 index 000000000..02da8472f --- /dev/null +++ b/src/shared/Core.UI/Commands/CredentialsCommand.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Threading; +using System.Threading.Tasks; +using GitCredentialManager.UI.ViewModels; + +namespace GitCredentialManager.UI.Commands +{ + public abstract class CredentialsCommand : HelperCommand + { + protected CredentialsCommand(ICommandContext context) + : base(context, "basic", "Show basic authentication prompt.") + { + AddOption( + new Option("--title", "Window title (optional).") + ); + + AddOption( + new Option("--resource", "Resource name or URL (optional).") + ); + + AddOption( + new Option("--username", "User name (optional).") + ); + + AddOption( + new Option("--no-logo", "Hide the Git Credential Manager logo and logotype.") + ); + + Handler = CommandHandler.Create(ExecuteAsync); + } + + private class CommandOptions + { + public string Title { get; set; } + public string Resource { get; set; } + public string UserName { get; set; } + public bool NoLogo { get; set; } + } + + private async Task ExecuteAsync(CommandOptions options) + { + var viewModel = new CredentialsViewModel(); + + viewModel.Title = !string.IsNullOrWhiteSpace(options.Title) + ? options.Title + : "Git Credential Manager"; + + viewModel.Description = !string.IsNullOrWhiteSpace(options.Resource) + ? $"Enter your credentials for '{options.Resource}'" + : "Enter your credentials"; + + if (!string.IsNullOrWhiteSpace(options.UserName)) + { + viewModel.UserName = options.UserName; + } + + viewModel.ShowProductHeader = !options.NoLogo; + + await ShowAsync(viewModel, CancellationToken.None); + + if (!viewModel.WindowResult) + { + throw new Exception("User cancelled dialog."); + } + + WriteResult( + new Dictionary + { + ["username"] = viewModel.UserName, + ["password"] = viewModel.Password + } + ); + return 0; + } + + protected abstract Task ShowAsync(CredentialsViewModel viewModel, CancellationToken ct); + } +} diff --git a/src/shared/Core.UI/ViewModels/CredentialsViewModel.cs b/src/shared/Core.UI/ViewModels/CredentialsViewModel.cs new file mode 100644 index 000000000..c93c8ff29 --- /dev/null +++ b/src/shared/Core.UI/ViewModels/CredentialsViewModel.cs @@ -0,0 +1,68 @@ +using System.ComponentModel; + +namespace GitCredentialManager.UI.ViewModels +{ + public class CredentialsViewModel : WindowViewModel + { + private string _userName; + private string _password; + private string _description; + private bool _showProductHeader; + private RelayCommand _signInCommand; + + public CredentialsViewModel() + { + SignInCommand = new RelayCommand(Accept, CanSignIn); + PropertyChanged += OnPropertyChanged; + } + + private void OnPropertyChanged(object sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(UserName): + case nameof(Password): + SignInCommand.RaiseCanExecuteChanged(); + break; + } + } + + private bool CanSignIn() + { + // Allow empty username or empty password, or both! + // This is what the older Windows API CredUIPromptForWindowsCredentials + // permitted so we should continue to support any possible scenarios. + return true; + } + + public string UserName + { + get => _userName; + set => SetAndRaisePropertyChanged(ref _userName, value); + } + + public string Password + { + get => _password; + set => SetAndRaisePropertyChanged(ref _password, value); + } + + public string Description + { + get => _description; + set => SetAndRaisePropertyChanged(ref _description, value); + } + + public bool ShowProductHeader + { + get => _showProductHeader; + set => _showProductHeader = value; + } + + public RelayCommand SignInCommand + { + get => _signInCommand; + set => SetAndRaisePropertyChanged(ref _signInCommand, value); + } + } +} diff --git a/src/shared/Core/Authentication/BasicAuthentication.cs b/src/shared/Core/Authentication/BasicAuthentication.cs index 0fac4d32b..83839fc23 100644 --- a/src/shared/Core/Authentication/BasicAuthentication.cs +++ b/src/shared/Core/Authentication/BasicAuthentication.cs @@ -1,17 +1,20 @@ using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; namespace GitCredentialManager.Authentication { public interface IBasicAuthentication { - ICredential GetCredentials(string resource, string userName); + Task GetCredentialsAsync(string resource, string userName); } public static class BasicAuthenticationExtensions { - public static ICredential GetCredentials(this IBasicAuthentication basicAuth, string resource) + public static Task GetCredentialsAsync(this IBasicAuthentication basicAuth, string resource) { - return basicAuth.GetCredentials(resource, null); + return basicAuth.GetCredentialsAsync(resource, null); } } @@ -25,17 +28,16 @@ public class BasicAuthentication : AuthenticationBase, IBasicAuthentication public BasicAuthentication(ICommandContext context) : base (context) { } - public ICredential GetCredentials(string resource, string userName) + public async Task GetCredentialsAsync(string resource, string userName) { EnsureArgument.NotNullOrWhiteSpace(resource, nameof(resource)); ThrowIfUserInteractionDisabled(); - // TODO: we only support system GUI prompts on Windows currently if (Context.Settings.IsGuiPromptsEnabled && Context.SessionManager.IsDesktopSession && - PlatformUtils.IsWindows()) + TryFindHelperExecutablePath(out string helperPath)) { - return GetCredentialsByUi(resource, userName); + return await GetCredentialsByUiAsync(helperPath, resource, userName); } ThrowIfTerminalPromptsDisabled(); @@ -64,14 +66,42 @@ private ICredential GetCredentialsByTty(string resource, string userName) return new GitCredential(userName, password); } - private ICredential GetCredentialsByUi(string resource, string userName) + private async Task GetCredentialsByUiAsync(string helperPath, string resource, string userName) { - if (!Context.SystemPrompts.ShowCredentialPrompt(resource, userName, out ICredential credential)) + var promptArgs = new StringBuilder("basic"); + + if (!string.IsNullOrWhiteSpace(resource)) + { + promptArgs.AppendFormat(" --resource {0}", QuoteCmdArg(resource)); + } + + if (!string.IsNullOrWhiteSpace(userName)) + { + promptArgs.AppendFormat(" --username {0}", QuoteCmdArg(userName)); + } + + IDictionary resultDict = await InvokeHelperAsync(helperPath, promptArgs.ToString(), null); + + if (!resultDict.TryGetValue("username", out userName)) + { + throw new Exception("Missing 'username' in response"); + } + + if (!resultDict.TryGetValue("password", out string password)) { - throw new Exception("User cancelled the authentication prompt."); + throw new Exception("Missing 'password' in response"); } - return credential; + return new GitCredential(userName, password); + } + + private bool TryFindHelperExecutablePath(out string path) + { + return TryFindHelperExecutablePath( + Constants.EnvironmentVariables.GcmUiHelper, + Constants.GitConfiguration.Credential.UiHelper, + Constants.DefaultUiHelper, + out path); } } } diff --git a/src/shared/Core/CommandContext.cs b/src/shared/Core/CommandContext.cs index 397d57bbb..0e784992a 100644 --- a/src/shared/Core/CommandContext.cs +++ b/src/shared/Core/CommandContext.cs @@ -66,11 +66,6 @@ public interface ICommandContext : IDisposable /// The current process environment. /// IEnvironment Environment { get; } - - /// - /// Native UI prompts. - /// - ISystemPrompts SystemPrompts { get; } } /// @@ -90,7 +85,6 @@ public CommandContext(string appPath) { FileSystem = new WindowsFileSystem(); SessionManager = new WindowsSessionManager(); - SystemPrompts = new WindowsSystemPrompts(); Environment = new WindowsEnvironment(FileSystem); Terminal = new WindowsTerminal(Trace); string gitPath = GetGitPath(Environment, FileSystem, Trace); @@ -106,7 +100,6 @@ public CommandContext(string appPath) { FileSystem = new MacOSFileSystem(); SessionManager = new MacOSSessionManager(); - SystemPrompts = new MacOSSystemPrompts(); Environment = new MacOSEnvironment(FileSystem); Terminal = new MacOSTerminal(Trace); string gitPath = GetGitPath(Environment, FileSystem, Trace); @@ -123,7 +116,6 @@ public CommandContext(string appPath) FileSystem = new LinuxFileSystem(); // TODO: support more than just 'Posix' or X11 SessionManager = new PosixSessionManager(); - SystemPrompts = new LinuxSystemPrompts(); Environment = new PosixEnvironment(FileSystem); Terminal = new LinuxTerminal(Trace); string gitPath = GetGitPath(Environment, FileSystem, Trace); @@ -142,9 +134,6 @@ public CommandContext(string appPath) HttpClientFactory = new HttpClientFactory(FileSystem, Trace, Settings, Streams); CredentialStore = new CredentialStore(this); - - // Set the parent window handle/ID - SystemPrompts.ParentWindowId = Settings.ParentWindowId; } private static string GetGitPath(IEnvironment environment, IFileSystem fileSystem, ITrace trace) @@ -203,8 +192,6 @@ private static string GetGitPath(IEnvironment environment, IFileSystem fileSyste public IEnvironment Environment { get; } - public ISystemPrompts SystemPrompts { get; } - #endregion #region IDisposable diff --git a/src/shared/Core/Constants.cs b/src/shared/Core/Constants.cs index e3a1d7d9d..665d99b23 100644 --- a/src/shared/Core/Constants.cs +++ b/src/shared/Core/Constants.cs @@ -8,6 +8,7 @@ public static class Constants public const string PersonalAccessTokenUserName = "PersonalAccessToken"; public const string DefaultCredentialNamespace = "git"; public const int DefaultAutoDetectProviderTimeoutMs = 2000; // 2 seconds + public const string DefaultUiHelper = "git-credential-manager-ui"; public const string ProviderIdAuto = "auto"; public const string AuthorityIdAuto = "auto"; @@ -87,6 +88,7 @@ public static class EnvironmentVariables public const string GpgExecutablePath = "GCM_GPG_PATH"; public const string GcmAutoDetectTimeout = "GCM_AUTODETECT_TIMEOUT"; public const string GcmGuiPromptsEnabled = "GCM_GUI_PROMPT"; + public const string GcmUiHelper = "GCM_UI_HELPER"; } public static class Http @@ -122,6 +124,7 @@ public static class Credential public const string UserName = "username"; public const string AutoDetectTimeout = "autoDetectTimeout"; public const string GuiPromptsEnabled = "guiPrompt"; + public const string UiHelper = "uiHelper"; } public static class Http diff --git a/src/shared/Core/GenericHostProvider.cs b/src/shared/Core/GenericHostProvider.cs index c0e794d4d..3f98eab0f 100644 --- a/src/shared/Core/GenericHostProvider.cs +++ b/src/shared/Core/GenericHostProvider.cs @@ -87,7 +87,7 @@ public override async Task GenerateCredentialAsync(InputArguments i } Context.Trace.WriteLine("Prompting for basic credentials..."); - return _basicAuth.GetCredentials(uri.AbsoluteUri, input.UserName); + return await _basicAuth.GetCredentialsAsync(uri.AbsoluteUri, input.UserName); } /// diff --git a/src/shared/Core/Interop/Linux/LinuxSystemPrompts.cs b/src/shared/Core/Interop/Linux/LinuxSystemPrompts.cs deleted file mode 100644 index 05342b0bf..000000000 --- a/src/shared/Core/Interop/Linux/LinuxSystemPrompts.cs +++ /dev/null @@ -1,13 +0,0 @@ - -namespace GitCredentialManager.Interop.Linux -{ - public class LinuxSystemPrompts : ISystemPrompts - { - public object ParentWindowId { get; set; } - - public bool ShowCredentialPrompt(string resource, string userName, out ICredential credential) - { - throw new System.NotImplementedException(); - } - } -} \ No newline at end of file diff --git a/src/shared/Core/Interop/MacOS/MacOSSystemPrompts.cs b/src/shared/Core/Interop/MacOS/MacOSSystemPrompts.cs deleted file mode 100644 index 9cbac2583..000000000 --- a/src/shared/Core/Interop/MacOS/MacOSSystemPrompts.cs +++ /dev/null @@ -1,13 +0,0 @@ - -namespace GitCredentialManager.Interop.MacOS -{ - public class MacOSSystemPrompts : ISystemPrompts - { - public object ParentWindowId { get; set; } - - public bool ShowCredentialPrompt(string resource, string userName, out ICredential credential) - { - throw new System.NotImplementedException(); - } - } -} diff --git a/src/shared/Git-Credential-Manager.UI.Avalonia/Commands/CredentialsCommandImpl.cs b/src/shared/Git-Credential-Manager.UI.Avalonia/Commands/CredentialsCommandImpl.cs new file mode 100644 index 000000000..876f95ab0 --- /dev/null +++ b/src/shared/Git-Credential-Manager.UI.Avalonia/Commands/CredentialsCommandImpl.cs @@ -0,0 +1,17 @@ +using System.Threading; +using System.Threading.Tasks; +using GitCredentialManager.UI.ViewModels; +using GitCredentialManager.UI.Views; + +namespace GitCredentialManager.UI.Commands +{ + public class CredentialsCommandImpl : CredentialsCommand + { + public CredentialsCommandImpl(ICommandContext context) : base(context) { } + + protected override Task ShowAsync(CredentialsViewModel viewModel, CancellationToken ct) + { + return AvaloniaUi.ShowViewAsync(viewModel, GetParentHandle(), ct); + } + } +} diff --git a/src/shared/Git-Credential-Manager.UI.Avalonia/Controls/TesterWindow.axaml b/src/shared/Git-Credential-Manager.UI.Avalonia/Controls/TesterWindow.axaml new file mode 100644 index 000000000..e10e5c401 --- /dev/null +++ b/src/shared/Git-Credential-Manager.UI.Avalonia/Controls/TesterWindow.axaml @@ -0,0 +1,49 @@ + + + + + + + + + + + + +