diff --git a/VERSION b/VERSION index ba4940bb1..b96853e5c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.7.1.0 +2.7.2.0 diff --git a/src/shared/Atlassian.Bitbucket/BitbucketAuthentication.cs b/src/shared/Atlassian.Bitbucket/BitbucketAuthentication.cs index def19970c..0a442cea7 100644 --- a/src/shared/Atlassian.Bitbucket/BitbucketAuthentication.cs +++ b/src/shared/Atlassian.Bitbucket/BitbucketAuthentication.cs @@ -108,7 +108,7 @@ public async Task GetCredentialsAsync(Uri targetUri, st private async Task GetCredentialsViaUiAsync( Uri targetUri, string userName, AuthenticationModes modes) { - var viewModel = new CredentialsViewModel(Context.Environment) + var viewModel = new CredentialsViewModel(Context.SessionManager) { ShowOAuth = (modes & AuthenticationModes.OAuth) != 0, ShowBasic = (modes & AuthenticationModes.Basic) != 0 @@ -259,7 +259,7 @@ public async Task CreateOAuthCredentialsAsync(InputArguments FailureResponseHtmlFormat = BitbucketResources.AuthenticationResponseFailureHtmlFormat }; - var browser = new OAuth2SystemWebBrowser(Context.Environment, browserOptions); + var browser = new OAuth2SystemWebBrowser(Context.SessionManager, browserOptions); var oauth2Client = _oauth2ClientRegistry.Get(input); var authCodeResult = await oauth2Client.GetAuthorizationCodeAsync(browser, CancellationToken.None); diff --git a/src/shared/Atlassian.Bitbucket/UI/Commands/CredentialsCommand.cs b/src/shared/Atlassian.Bitbucket/UI/Commands/CredentialsCommand.cs index 72c20f513..0e4b58ced 100644 --- a/src/shared/Atlassian.Bitbucket/UI/Commands/CredentialsCommand.cs +++ b/src/shared/Atlassian.Bitbucket/UI/Commands/CredentialsCommand.cs @@ -32,7 +32,7 @@ protected CredentialsCommand(ICommandContext context) private async Task ExecuteAsync(Uri url, string userName, bool showOAuth, bool showBasic) { - var viewModel = new CredentialsViewModel(Context.Environment) + var viewModel = new CredentialsViewModel(Context.SessionManager) { Url = url, UserName = userName, diff --git a/src/shared/Atlassian.Bitbucket/UI/ViewModels/CredentialsViewModel.cs b/src/shared/Atlassian.Bitbucket/UI/ViewModels/CredentialsViewModel.cs index d4e8e51c9..e904e393b 100644 --- a/src/shared/Atlassian.Bitbucket/UI/ViewModels/CredentialsViewModel.cs +++ b/src/shared/Atlassian.Bitbucket/UI/ViewModels/CredentialsViewModel.cs @@ -9,7 +9,7 @@ namespace Atlassian.Bitbucket.UI.ViewModels { public class CredentialsViewModel : WindowViewModel { - private readonly IEnvironment _environment; + private readonly ISessionManager _sessionManager; private Uri _url; private string _userName; @@ -22,11 +22,11 @@ public CredentialsViewModel() // Constructor the XAML designer } - public CredentialsViewModel(IEnvironment environment) + public CredentialsViewModel(ISessionManager sessionManager) { - EnsureArgument.NotNull(environment, nameof(environment)); + EnsureArgument.NotNull(sessionManager, nameof(sessionManager)); - _environment = environment; + _sessionManager = sessionManager; Title = "Connect to Bitbucket"; LoginCommand = new RelayCommand(AcceptBasic, CanLogin); @@ -77,7 +77,7 @@ private void ForgotPassword() ? new Uri(BitbucketConstants.HelpUrls.PasswordReset) : new Uri(_url, BitbucketConstants.HelpUrls.DataCenterPasswordReset); - BrowserUtils.OpenDefaultBrowser(_environment, passwordResetUri); + _sessionManager.OpenBrowser(passwordResetUri); } private void SignUp() @@ -86,7 +86,7 @@ private void SignUp() ? new Uri(BitbucketConstants.HelpUrls.SignUp) : new Uri(_url, BitbucketConstants.HelpUrls.DataCenterLogin); - BrowserUtils.OpenDefaultBrowser(_environment, signUpUri); + _sessionManager.OpenBrowser(signUpUri); } public Uri Url diff --git a/src/shared/Core.Tests/Authentication/OAuth2SystemWebBrowserTests.cs b/src/shared/Core.Tests/Authentication/OAuth2SystemWebBrowserTests.cs index 2d80c94ab..cea6abe17 100644 --- a/src/shared/Core.Tests/Authentication/OAuth2SystemWebBrowserTests.cs +++ b/src/shared/Core.Tests/Authentication/OAuth2SystemWebBrowserTests.cs @@ -10,9 +10,9 @@ public class OAuth2SystemWebBrowserTests [Fact] public void OAuth2SystemWebBrowser_UpdateRedirectUri_NonLoopback_ThrowsError() { - var env = new TestEnvironment(); + var sm = new TestSessionManager(); var options = new OAuth2WebBrowserOptions(); - var browser = new OAuth2SystemWebBrowser(env, options); + var browser = new OAuth2SystemWebBrowser(sm, options); Assert.Throws(() => browser.UpdateRedirectUri(new Uri("http://example.com"))); } @@ -28,9 +28,9 @@ public void OAuth2SystemWebBrowser_UpdateRedirectUri_NonLoopback_ThrowsError() [InlineData("http://127.0.0.7:1234/oauth-callback/", "http://127.0.0.7:1234/oauth-callback/")] public void OAuth2SystemWebBrowser_UpdateRedirectUri_SpecificPort(string input, string expected) { - var env = new TestEnvironment(); + var sm = new TestSessionManager(); var options = new OAuth2WebBrowserOptions(); - var browser = new OAuth2SystemWebBrowser(env, options); + var browser = new OAuth2SystemWebBrowser(sm, options); Uri actualUri = browser.UpdateRedirectUri(new Uri(input)); @@ -48,9 +48,9 @@ public void OAuth2SystemWebBrowser_UpdateRedirectUri_SpecificPort(string input, [InlineData("http://127.0.0.7/oauth-callback/")] public void OAuth2SystemWebBrowser_UpdateRedirectUri_AnyPort(string input) { - var env = new TestEnvironment(); + var sm = new TestSessionManager(); var options = new OAuth2WebBrowserOptions(); - var browser = new OAuth2SystemWebBrowser(env, options); + var browser = new OAuth2SystemWebBrowser(sm, options); var inputUri = new Uri(input); Uri actualUri = browser.UpdateRedirectUri(inputUri); diff --git a/src/shared/Core/Authentication/MicrosoftAuthentication.cs b/src/shared/Core/Authentication/MicrosoftAuthentication.cs index 12bccf5fe..5e2eca63c 100644 --- a/src/shared/Core/Authentication/MicrosoftAuthentication.cs +++ b/src/shared/Core/Authentication/MicrosoftAuthentication.cs @@ -336,7 +336,7 @@ private async Task UseDefaultAccountAsync(string userName) } } - var viewModel = new DefaultAccountViewModel(Context.Environment) + var viewModel = new DefaultAccountViewModel(Context.SessionManager) { UserName = userName }; @@ -754,10 +754,33 @@ private static EmbeddedWebViewOptions GetEmbeddedWebViewOptions() }; } - private static SystemWebViewOptions GetSystemWebViewOptions() + private SystemWebViewOptions GetSystemWebViewOptions() { // TODO: add nicer HTML success and error pages - return new SystemWebViewOptions(); + return new SystemWebViewOptions + { + OpenBrowserAsync = OpenBrowserFunc + }; + + // We have special handling for Linux and WSL to open the system browser + // so we need to use our own function here. Sorry MSAL! + Task OpenBrowserFunc(Uri uri) + { + try + { + Context.SessionManager.OpenBrowser(uri); + } + catch (Exception ex) + { + Context.Trace.WriteLine("Failed to open system web browser - using MSAL fallback"); + Context.Trace.WriteException(ex); + + // Fallback to MSAL's default browser opening logic, preferring Edge. + return SystemWebViewOptions.OpenWithChromeEdgeBrowserAsync(uri); + } + + return Task.CompletedTask; + } } private Task ShowDeviceCodeInTty(DeviceCodeResult dcr) @@ -859,8 +882,14 @@ private void EnsureCanUseEmbeddedWebView() private bool CanUseSystemWebView(IPublicClientApplication app, Uri redirectUri) { + // // MSAL requires the application redirect URI is a loopback address to use the System WebView - return Context.SessionManager.IsWebBrowserAvailable && app.IsSystemWebViewAvailable && redirectUri.IsLoopback; + // + // Note: we do NOT check the MSAL 'IsSystemWebViewAvailable' property as it only + // looks for the presence of the DISPLAY environment variable on UNIX systems. + // This is insufficient as we instead handle launching the default browser ourselves. + // + return Context.SessionManager.IsWebBrowserAvailable && redirectUri.IsLoopback; } private void EnsureCanUseSystemWebView(IPublicClientApplication app, Uri redirectUri) @@ -871,12 +900,6 @@ private void EnsureCanUseSystemWebView(IPublicClientApplication app, Uri redirec "System web view is not available without a way to start a browser."); } - if (!app.IsSystemWebViewAvailable) - { - throw new Trace2InvalidOperationException(Context.Trace2, - "System web view is not available on this platform."); - } - if (!redirectUri.IsLoopback) { throw new Trace2InvalidOperationException(Context.Trace2, diff --git a/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs b/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs index af261a651..34d6cfbe7 100644 --- a/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs +++ b/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Net; using System.Net.Sockets; using System.Threading; @@ -33,15 +32,15 @@ public class OAuth2WebBrowserOptions public class OAuth2SystemWebBrowser : IOAuth2WebBrowser { - private readonly IEnvironment _environment; + private readonly ISessionManager _sessionManager; private readonly OAuth2WebBrowserOptions _options; - public OAuth2SystemWebBrowser(IEnvironment environment, OAuth2WebBrowserOptions options) + public OAuth2SystemWebBrowser(ISessionManager sessionManager, OAuth2WebBrowserOptions options) { - EnsureArgument.NotNull(environment, nameof(environment)); + EnsureArgument.NotNull(sessionManager, nameof(sessionManager)); EnsureArgument.NotNull(options, nameof(options)); - _environment = environment; + _sessionManager = sessionManager; _options = options; } @@ -71,7 +70,7 @@ public async Task GetAuthenticationCodeAsync(Uri authorizationUri, Uri redi Task interceptTask = InterceptRequestsAsync(redirectUri, ct); - BrowserUtils.OpenDefaultBrowser(_environment, authorizationUri); + _sessionManager.OpenBrowser(authorizationUri); return await interceptTask; } diff --git a/src/shared/Core/Authentication/OAuthAuthentication.cs b/src/shared/Core/Authentication/OAuthAuthentication.cs index 641baeb08..a8de4ecb6 100644 --- a/src/shared/Core/Authentication/OAuthAuthentication.cs +++ b/src/shared/Core/Authentication/OAuthAuthentication.cs @@ -187,7 +187,7 @@ public async Task GetTokenByBrowserAsync(OAuth2Client client, } var browserOptions = new OAuth2WebBrowserOptions(); - var browser = new OAuth2SystemWebBrowser(Context.Environment, browserOptions); + var browser = new OAuth2SystemWebBrowser(Context.SessionManager, browserOptions); var authCode = await client.GetAuthorizationCodeAsync(scopes, browser, CancellationToken.None); return await client.GetTokenByAuthorizationCodeAsync(authCode, CancellationToken.None); } @@ -241,7 +241,7 @@ public async Task GetTokenByDeviceCodeAsync(OAuth2Client clie private Task ShowDeviceCodeViaUiAsync(OAuth2DeviceCodeResult dcr, CancellationToken ct) { - var viewModel = new DeviceCodeViewModel(Context.Environment) + var viewModel = new DeviceCodeViewModel(Context.SessionManager) { UserCode = dcr.UserCode, VerificationUrl = dcr.VerificationUri.ToString(), diff --git a/src/shared/Core/BrowserUtils.cs b/src/shared/Core/BrowserUtils.cs deleted file mode 100644 index 6e908d1fc..000000000 --- a/src/shared/Core/BrowserUtils.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System; -using System.Diagnostics; - -namespace GitCredentialManager -{ - public static class BrowserUtils - { - public static void OpenDefaultBrowser(IEnvironment environment, string url) - { - if (!Uri.TryCreate(url, UriKind.Absolute, out Uri uri)) - { - throw new ArgumentException($"Not a valid URI: '{url}'"); - } - - OpenDefaultBrowser(environment, uri); - } - - public static void OpenDefaultBrowser(IEnvironment environment, Uri uri) - { - if (!uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && - !uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) - { - throw new ArgumentException("Can only open HTTP/HTTPS URIs", nameof(uri)); - } - - string url = uri.ToString(); - - ProcessStartInfo psi; - if (PlatformUtils.IsLinux()) - { - // - // On Linux, 'shell execute' utilities like xdg-open launch a process without - // detaching from the standard in/out descriptors. Some applications (like - // Chromium) write messages to stdout, which is currently hooked up and being - // consumed by Git, and cause errors. - // - // Sadly, the Framework does not allow us to redirect standard streams if we - // set ProcessStartInfo::UseShellExecute = true, so we must manually launch - // these utilities and redirect the standard streams manually. - // - // We try and use the same 'shell execute' utilities as the Framework does, - // searching for them in the same order until we find one. - // - if (!TryGetLinuxShellExecuteHandler(environment, out string shellExecPath)) - { - throw new Exception("Failed to locate a utility to launch the default web browser."); - } - - psi = new ProcessStartInfo(shellExecPath, url) - { - RedirectStandardOutput = true, - // Ok to redirect stderr for non-git-related processes - RedirectStandardError = true - }; - } - else - { - // On Windows and macOS, `ShellExecute` and `/usr/bin/open` disconnect the child process - // from our standard in/out streams, so we can just use the Framework to do this. - psi = new ProcessStartInfo(url) {UseShellExecute = true}; - } - - // We purposefully do not use a ChildProcess here, as the purpose of that - // class is to allow us to collect child process information using TRACE2. - // Since we will not be collecting TRACE2 data from the browser, there - // is no need to add the extra overhead associated with ChildProcess here. - Process.Start(psi); - } - - public static bool TryGetLinuxShellExecuteHandler(IEnvironment env, out string shellExecPath) - { - // One additional 'shell execute' utility we also attempt to use over the Framework - // is `wslview` that is commonly found on WSL (Windows Subsystem for Linux) distributions - // that opens the browser on the Windows host. - string[] shellHandlers = { "xdg-open", "gnome-open", "kfmclient", WslUtils.WslViewShellHandlerName }; - foreach (string shellExec in shellHandlers) - { - if (env.TryLocateExecutable(shellExec, out shellExecPath)) - { - return true; - } - } - - shellExecPath = null; - return false; - } - } -} diff --git a/src/shared/Core/CommandContext.cs b/src/shared/Core/CommandContext.cs index b5bade471..225860a03 100644 --- a/src/shared/Core/CommandContext.cs +++ b/src/shared/Core/CommandContext.cs @@ -103,7 +103,7 @@ public CommandContext() { FileSystem = new WindowsFileSystem(); Environment = new WindowsEnvironment(FileSystem); - SessionManager = new WindowsSessionManager(Environment, FileSystem); + SessionManager = new WindowsSessionManager(Trace, Environment, FileSystem); ProcessManager = new WindowsProcessManager(Trace2); Terminal = new WindowsTerminal(Trace, Trace2); string gitPath = GetGitPath(Environment, FileSystem, Trace); @@ -120,7 +120,7 @@ public CommandContext() { FileSystem = new MacOSFileSystem(); Environment = new MacOSEnvironment(FileSystem); - SessionManager = new MacOSSessionManager(Environment, FileSystem); + SessionManager = new MacOSSessionManager(Trace, Environment, FileSystem); ProcessManager = new ProcessManager(Trace2); Terminal = new MacOSTerminal(Trace, Trace2); string gitPath = GetGitPath(Environment, FileSystem, Trace); @@ -137,7 +137,7 @@ public CommandContext() { FileSystem = new LinuxFileSystem(); Environment = new PosixEnvironment(FileSystem); - SessionManager = new LinuxSessionManager(Environment, FileSystem); + SessionManager = new LinuxSessionManager(Trace, Environment, FileSystem); ProcessManager = new ProcessManager(Trace2); Terminal = new LinuxTerminal(Trace, Trace2); string gitPath = GetGitPath(Environment, FileSystem, Trace); diff --git a/src/shared/Core/ISessionManager.cs b/src/shared/Core/ISessionManager.cs index 3804518e7..0ad0204c4 100644 --- a/src/shared/Core/ISessionManager.cs +++ b/src/shared/Core/ISessionManager.cs @@ -1,3 +1,6 @@ +using System; +using System.Diagnostics; + namespace GitCredentialManager { public interface ISessionManager @@ -13,18 +16,41 @@ public interface ISessionManager /// /// True if the session can display a web browser, false otherwise. bool IsWebBrowserAvailable { get; } + + /// + /// Open the system web browser to the specified URL. + /// + /// to open the browser at. + /// Thrown if is false. + void OpenBrowser(Uri uri); + } + + public static class SessionManagerExtensions + { + public static void OpenBrowser(this ISessionManager sm, string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri uri)) + { + throw new ArgumentException($"Not a valid URI: '{url}'"); + } + + sm.OpenBrowser(uri); + } } public abstract class SessionManager : ISessionManager { + protected ITrace Trace { get; } protected IEnvironment Environment { get; } protected IFileSystem FileSystem { get; } - protected SessionManager(IEnvironment env, IFileSystem fs) + protected SessionManager(ITrace trace, IEnvironment env, IFileSystem fs) { + EnsureArgument.NotNull(trace, nameof(trace)); EnsureArgument.NotNull(env, nameof(env)); EnsureArgument.NotNull(fs, nameof(fs)); + Trace = trace; Environment = env; FileSystem = fs; } @@ -32,5 +58,23 @@ protected SessionManager(IEnvironment env, IFileSystem fs) public abstract bool IsDesktopSession { get; } public virtual bool IsWebBrowserAvailable => IsDesktopSession; + + public void OpenBrowser(Uri uri) + { + if (!uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Can only open HTTP/HTTPS URIs", nameof(uri)); + } + + OpenBrowserInternal(uri.ToString()); + } + + protected virtual void OpenBrowserInternal(string url) + { + Trace.WriteLine("Opening browser using framework shell-execute: " + url); + var psi = new ProcessStartInfo(url) { UseShellExecute = true }; + Process.Start(psi); + } } } diff --git a/src/shared/Core/Interop/Linux/LinuxSessionManager.cs b/src/shared/Core/Interop/Linux/LinuxSessionManager.cs index 2147289ac..9033b4cf2 100644 --- a/src/shared/Core/Interop/Linux/LinuxSessionManager.cs +++ b/src/shared/Core/Interop/Linux/LinuxSessionManager.cs @@ -1,3 +1,5 @@ +using System; +using System.Diagnostics; using GitCredentialManager.Interop.Posix; namespace GitCredentialManager.Interop.Linux; @@ -6,7 +8,7 @@ public class LinuxSessionManager : PosixSessionManager { private bool? _isWebBrowserAvailable; - public LinuxSessionManager(IEnvironment env, IFileSystem fs) : base(env, fs) + public LinuxSessionManager(ITrace trace, IEnvironment env, IFileSystem fs) : base(trace, env, fs) { PlatformUtils.EnsureLinux(); } @@ -18,47 +20,147 @@ public override bool IsWebBrowserAvailable return _isWebBrowserAvailable ??= GetWebBrowserAvailable(); } } - + + protected override void OpenBrowserInternal(string url) + { + // + // On Linux, 'shell execute' utilities like xdg-open launch a process without + // detaching from the standard in/out descriptors. Some applications (like + // Chromium) write messages to stdout, which is currently hooked up and being + // consumed by Git, and cause errors. + // + // Sadly, the Framework does not allow us to redirect standard streams if we + // set ProcessStartInfo::UseShellExecute = true, so we must manually launch + // these utilities and redirect the standard streams manually. + // + // We try and use the same 'shell execute' utilities as the Framework does, + // searching for them in the same order until we find one. + // + if (!TryGetShellExecuteHandler(Environment, out string shellExecPath)) + { + throw new Exception("Failed to locate a utility to launch the default web browser."); + } + + Trace.WriteLine($"Opening browser using '{shellExecPath}: {url}"); + + var psi = new ProcessStartInfo(shellExecPath, url) + { + RedirectStandardOutput = true, + // Ok to redirect stderr for non-git-related processes + RedirectStandardError = true + }; + + Process.Start(psi); + } + private bool GetWebBrowserAvailable() { + // We need a shell execute handler to be able to launch to browser + if (!TryGetShellExecuteHandler(Environment, out _)) + { + Trace.WriteLine("Could not locate a shell execute handler for Linux - browser is not available."); + return false; + } + // If this is a Windows Subsystem for Linux distribution we may - // be able to launch the web browser of the host Windows OS. + // be able to launch the web browser of the host Windows OS, but + // there are further checks to do on the Windows host's session. + // + // If we are in Windows logon session 0 then the user can never interact, + // even in the WinSta0 window station. This is typical when SSH-ing into a + // Windows 10+ machine using the default OpenSSH Server configuration, + // which runs in the 'services' session 0. + // + // If we're in any other session, and in the WinSta0 window station then + // the user can possibly interact. However, since it's hard to determine + // the window station from PowerShell cmdlets (we'd need to write P/Invoke + // code and that's just messy and too many levels of indirection quite + // frankly!) we just assume any non session 0 is interactive. + // + // This assumption doesn't hold true if the user has changed the user that + // the OpenSSH Server service runs as (not a built-in NT service) *AND* + // they've SSH-ed into the Windows host (and then started a WSL shell). + // This feels like a very small subset of users... + // if (WslUtils.IsWslDistribution(Environment, FileSystem, out _)) { - // We need a shell execute handler to be able to launch to browser - if (!BrowserUtils.TryGetLinuxShellExecuteHandler(Environment, out _)) + if (WslUtils.GetWindowsSessionId(FileSystem) == 0) { + Trace.WriteLine("This is a WSL distribution, but Windows session 0 was detected - browser is not available."); return false; } - // - // If we are in Windows logon session 0 then the user can never interact, - // even in the WinSta0 window station. This is typical when SSH-ing into a - // Windows 10+ machine using the default OpenSSH Server configuration, - // which runs in the 'services' session 0. - // - // If we're in any other session, and in the WinSta0 window station then - // the user can possibly interact. However, since it's hard to determine - // the window station from PowerShell cmdlets (we'd need to write P/Invoke - // code and that's just messy and too many levels of indirection quite - // frankly!) we just assume any non session 0 is interactive. - // - // This assumption doesn't hold true if the user has changed the user that - // the OpenSSH Server service runs as (not a built-in NT service) *AND* - // they've SSH-ed into the Windows host (and then started a WSL shell). - // This feels like a very small subset of users... - // - if (WslUtils.GetWindowsSessionId(FileSystem) == 0) + // Not on session 0 - we assume the user can interact with browser on Windows. + Trace.WriteLine("This is a WSL distribution - browser is available."); + return true; + } + + // + // We may also be able to launch a browser if we're inside a Visual Studio Code remote session. + // VSCode overrides the BROWSER environment variable to a script that allows the user to open + // the browser on their client machine. + // + // Even though we can start a browser, one piece of critical functionality we need is the ability + // to have that browser be able to connect back to GCM over localhost. There are several types + // of VSCode remote session, and only some of them automatically forward ports in such a way that + // the client browser can automatically connect back to GCM over localhost. + // + // * SSH [OK] + // Connection over SSH to a remote machine. + // + // * Dev Containers [OK] + // Connection to a container. + // + // * Dev Tunnels [Not OK - forwarded ports not accessible on the client via localhost] + // Connection to a remote machine over the Internet using Microsoft Dev Tunnels. + // + // * WSL [Ignored - already handled above] + // + if (Environment.Variables.ContainsKey("VSCODE_IPC_HOOK_CLI") && + Environment.Variables.ContainsKey("BROWSER")) + { + // Looking for SSH_CONNECTION tells us we're connected via SSH. + // HOWEVER, we may also see SSH_CONNECTION in a Dev Tunnel session if the tunnel server + // process was started within an SSH session (and the SSH_CONNECTION environment variable + // was inherited). + // We therefore check for the absence of the SSH_TTY variable, which gets unset + // in Dev Tunnel sessions but is always still set in regular SSH sessions. + if (Environment.Variables.ContainsKey("SSH_CONNECTION") && + !Environment.Variables.ContainsKey("SSH_TTY")) { - return false; + Trace.WriteLine("VSCode (Remote SSH) detected - browser is available."); + return true; } - // If we are not in session 0, or we cannot get the Windows session ID, - // assume that we *CAN* launch the browser so that users are never blocked. - return true; + if (Environment.Variables.ContainsKey("REMOTE_CONTAINERS")) + { + Trace.WriteLine("VSCode (Dev Containers) detected - browser is available."); + return true; + } + + Trace.WriteLine("VSCode (Remote Tunnel) detected - browser is not available."); + return false; } - // We require an interactive desktop session to be able to launch a browser + // We need a desktop session to be able to launch the browser in the general case return IsDesktopSession; } + + private static bool TryGetShellExecuteHandler(IEnvironment env, out string shellExecPath) + { + // One additional 'shell execute' utility we also attempt to use over the Framework + // is `wslview` that is commonly found on WSL (Windows Subsystem for Linux) distributions + // that opens the browser on the Windows host. + string[] shellHandlers = { "xdg-open", "gnome-open", "kfmclient", WslUtils.WslViewShellHandlerName }; + foreach (string shellExec in shellHandlers) + { + if (env.TryLocateExecutable(shellExec, out shellExecPath)) + { + return true; + } + } + + shellExecPath = null; + return false; + } } diff --git a/src/shared/Core/Interop/MacOS/MacOSSessionManager.cs b/src/shared/Core/Interop/MacOS/MacOSSessionManager.cs index 584965ca1..047a4dd96 100644 --- a/src/shared/Core/Interop/MacOS/MacOSSessionManager.cs +++ b/src/shared/Core/Interop/MacOS/MacOSSessionManager.cs @@ -5,7 +5,7 @@ namespace GitCredentialManager.Interop.MacOS { public class MacOSSessionManager : PosixSessionManager { - public MacOSSessionManager(IEnvironment env, IFileSystem fs) : base(env, fs) + public MacOSSessionManager(ITrace trace, IEnvironment env, IFileSystem fs) : base(trace, env, fs) { PlatformUtils.EnsureMacOS(); } diff --git a/src/shared/Core/Interop/Posix/PosixSessionManager.cs b/src/shared/Core/Interop/Posix/PosixSessionManager.cs index 8709e12e7..4aee424ed 100644 --- a/src/shared/Core/Interop/Posix/PosixSessionManager.cs +++ b/src/shared/Core/Interop/Posix/PosixSessionManager.cs @@ -2,7 +2,7 @@ namespace GitCredentialManager.Interop.Posix { public abstract class PosixSessionManager : SessionManager { - protected PosixSessionManager(IEnvironment env, IFileSystem fs) : base(env, fs) + protected PosixSessionManager(ITrace trace, IEnvironment env, IFileSystem fs) : base(trace, env, fs) { PlatformUtils.EnsurePosix(); } diff --git a/src/shared/Core/Interop/Windows/WindowsSessionManager.cs b/src/shared/Core/Interop/Windows/WindowsSessionManager.cs index d87d76347..2200ae6a5 100644 --- a/src/shared/Core/Interop/Windows/WindowsSessionManager.cs +++ b/src/shared/Core/Interop/Windows/WindowsSessionManager.cs @@ -5,7 +5,7 @@ namespace GitCredentialManager.Interop.Windows { public class WindowsSessionManager : SessionManager { - public WindowsSessionManager(IEnvironment env, IFileSystem fs) : base(env, fs) + public WindowsSessionManager(ITrace trace, IEnvironment env, IFileSystem fs) : base(trace, env, fs) { PlatformUtils.EnsureWindows(); } diff --git a/src/shared/Core/UI/Commands/DefaultAccountCommand.cs b/src/shared/Core/UI/Commands/DefaultAccountCommand.cs index 503654a0b..4da8c0ffc 100644 --- a/src/shared/Core/UI/Commands/DefaultAccountCommand.cs +++ b/src/shared/Core/UI/Commands/DefaultAccountCommand.cs @@ -28,7 +28,7 @@ protected DefaultAccountCommand(ICommandContext context) private async Task ExecuteAsync(string title, string userName, bool noLogo) { - var viewModel = new DefaultAccountViewModel(Context.Environment) + var viewModel = new DefaultAccountViewModel(Context.SessionManager) { Title = !string.IsNullOrWhiteSpace(title) ? title diff --git a/src/shared/Core/UI/Commands/DeviceCodeCommand.cs b/src/shared/Core/UI/Commands/DeviceCodeCommand.cs index 3c3f9eb6f..d260b077c 100644 --- a/src/shared/Core/UI/Commands/DeviceCodeCommand.cs +++ b/src/shared/Core/UI/Commands/DeviceCodeCommand.cs @@ -24,7 +24,7 @@ protected DeviceCodeCommand(ICommandContext context) private async Task ExecuteAsync(string code, string url, bool noLogo) { - var viewModel = new DeviceCodeViewModel(Context.Environment) + var viewModel = new DeviceCodeViewModel(Context.SessionManager) { UserCode = code, VerificationUrl = url, diff --git a/src/shared/Core/UI/ViewModels/DefaultAccountViewModel.cs b/src/shared/Core/UI/ViewModels/DefaultAccountViewModel.cs index f91ef6394..07d05b948 100644 --- a/src/shared/Core/UI/ViewModels/DefaultAccountViewModel.cs +++ b/src/shared/Core/UI/ViewModels/DefaultAccountViewModel.cs @@ -4,7 +4,7 @@ namespace GitCredentialManager.UI.ViewModels; public class DefaultAccountViewModel : WindowViewModel { - private readonly IEnvironment _env; + private readonly ISessionManager _sessionManager; private bool _showProductHeader = true; private string _userName; @@ -17,9 +17,9 @@ public DefaultAccountViewModel() // For designer only } - public DefaultAccountViewModel(IEnvironment environment) : this() + public DefaultAccountViewModel(ISessionManager sessionManager) : this() { - _env = environment; + _sessionManager = sessionManager; ContinueCommand = new RelayCommand(Continue); OtherAccountCommand = new RelayCommand(OtherAccount); @@ -40,7 +40,7 @@ private void Continue() private void OpenLink() { - BrowserUtils.OpenDefaultBrowser(_env, Link); + _sessionManager.OpenBrowser(Link); } public bool UseDefaultAccount { get; private set; } diff --git a/src/shared/Core/UI/ViewModels/DeviceCodeViewModel.cs b/src/shared/Core/UI/ViewModels/DeviceCodeViewModel.cs index 45112518d..c17b365de 100644 --- a/src/shared/Core/UI/ViewModels/DeviceCodeViewModel.cs +++ b/src/shared/Core/UI/ViewModels/DeviceCodeViewModel.cs @@ -4,7 +4,7 @@ namespace GitCredentialManager.UI.ViewModels { public class DeviceCodeViewModel : WindowViewModel { - private readonly IEnvironment _environment; + private readonly ISessionManager _sessionManager; private ICommand _verificationUrlCommand; private string _verificationUrl; @@ -16,11 +16,11 @@ public DeviceCodeViewModel() // Constructor the XAML designer } - public DeviceCodeViewModel(IEnvironment environment) + public DeviceCodeViewModel(ISessionManager sessionManager) { - EnsureArgument.NotNull(environment, nameof(environment)); + EnsureArgument.NotNull(sessionManager, nameof(sessionManager)); - _environment = environment; + _sessionManager = sessionManager; Title = "Device code authentication"; VerificationUrlCommand = new RelayCommand(OpenVerificationUrl); @@ -28,7 +28,7 @@ public DeviceCodeViewModel(IEnvironment environment) private void OpenVerificationUrl() { - BrowserUtils.OpenDefaultBrowser(_environment, VerificationUrl); + _sessionManager.OpenBrowser(VerificationUrl); } public string UserCode diff --git a/src/shared/Core/WslUtils.cs b/src/shared/Core/WslUtils.cs index 1db63d329..977677d6e 100644 --- a/src/shared/Core/WslUtils.cs +++ b/src/shared/Core/WslUtils.cs @@ -195,8 +195,16 @@ public static int GetWindowsSessionId(IFileSystem fs) const string script = @"(Get-Process -ID $PID).SessionId"; using (Process proc = CreateWindowsShellProcess(fs, WindowsShell.PowerShell, script)) { - proc.Start(); - proc.WaitForExit(); + try + { + proc.Start(); + proc.WaitForExit(); + } + catch + { + // Unable to start the process, return unknown session ID + return -1; + } if (proc.ExitCode == 0) { diff --git a/src/shared/GitHub/GitHubAuthentication.cs b/src/shared/GitHub/GitHubAuthentication.cs index 13c162495..f490dd0ad 100644 --- a/src/shared/GitHub/GitHubAuthentication.cs +++ b/src/shared/GitHub/GitHubAuthentication.cs @@ -100,7 +100,7 @@ public async Task SelectAccountAsync(Uri targetUri, IEnumerable return string.IsNullOrWhiteSpace(selectedAccount) ? null : selectedAccount; } - var viewModel = new SelectAccountViewModel(Context.Environment, accounts); + var viewModel = new SelectAccountViewModel(Context.SessionManager, accounts); if (!GitHubHostProvider.IsGitHubDotCom(targetUri)) { @@ -168,7 +168,7 @@ public async Task GetAuthenticationAsync(Uri targetU private async Task GetAuthenticationViaUiAsync( Uri targetUri, string userName, AuthenticationModes modes) { - var viewModel = new CredentialsViewModel(Context.Environment, Context.ProcessManager) + var viewModel = new CredentialsViewModel(Context.SessionManager, Context.ProcessManager) { ShowBrowserLogin = (modes & AuthenticationModes.Browser) != 0, ShowDeviceLogin = (modes & AuthenticationModes.Device) != 0, @@ -364,7 +364,7 @@ public async Task GetTwoFactorCodeAsync(Uri targetUri, bool isSms) private async Task GetTwoFactorCodeViaUiAsync(Uri targetUri, bool isSms) { - var viewModel = new TwoFactorViewModel(Context.Environment, Context.ProcessManager) + var viewModel = new TwoFactorViewModel(Context.SessionManager, Context.ProcessManager) { IsSms = isSms }; @@ -423,7 +423,7 @@ public async Task GetOAuthTokenViaBrowserAsync(Uri targetUri, SuccessResponseHtml = GitHubResources.AuthenticationResponseSuccessHtml, FailureResponseHtmlFormat = GitHubResources.AuthenticationResponseFailureHtmlFormat }; - var browser = new OAuth2SystemWebBrowser(Context.Environment, browserOptions); + var browser = new OAuth2SystemWebBrowser(Context.SessionManager, browserOptions); // If we have a login hint we should pass this to GitHub as an extra query parameter IDictionary queryParams = null; @@ -495,7 +495,7 @@ public async Task GetOAuthTokenViaDeviceCodeAsync(Uri targetU private Task ShowDeviceCodeViaUiAsync(OAuth2DeviceCodeResult dcr, CancellationToken ct) { - var viewModel = new DeviceCodeViewModel(Context.Environment) + var viewModel = new DeviceCodeViewModel(Context.SessionManager) { UserCode = dcr.UserCode, VerificationUrl = dcr.VerificationUri.ToString(), diff --git a/src/shared/GitHub/UI/Commands/CredentialsCommand.cs b/src/shared/GitHub/UI/Commands/CredentialsCommand.cs index 0df9e5389..f14b3cb3e 100644 --- a/src/shared/GitHub/UI/Commands/CredentialsCommand.cs +++ b/src/shared/GitHub/UI/Commands/CredentialsCommand.cs @@ -41,7 +41,7 @@ protected CredentialsCommand(ICommandContext context) private async Task ExecuteAsync(string userName, string enterpriseUrl, bool basic, bool browser, bool device, bool pat, bool all) { - var viewModel = new CredentialsViewModel(Context.Environment, Context.ProcessManager) + var viewModel = new CredentialsViewModel(Context.SessionManager, Context.ProcessManager) { ShowBrowserLogin = all || browser, ShowDeviceLogin = all || device, diff --git a/src/shared/GitHub/UI/Commands/DeviceCommand.cs b/src/shared/GitHub/UI/Commands/DeviceCommand.cs index 7ab817127..7ffdae7ec 100644 --- a/src/shared/GitHub/UI/Commands/DeviceCommand.cs +++ b/src/shared/GitHub/UI/Commands/DeviceCommand.cs @@ -23,7 +23,7 @@ protected DeviceCodeCommand(ICommandContext context) private async Task ExecuteAsync(string code, string url) { - var viewModel = new DeviceCodeViewModel(Context.Environment) + var viewModel = new DeviceCodeViewModel(Context.SessionManager) { UserCode = code, VerificationUrl = url, diff --git a/src/shared/GitHub/UI/Commands/SelectAccountCommand.cs b/src/shared/GitHub/UI/Commands/SelectAccountCommand.cs index d7ba18156..b6d446a14 100644 --- a/src/shared/GitHub/UI/Commands/SelectAccountCommand.cs +++ b/src/shared/GitHub/UI/Commands/SelectAccountCommand.cs @@ -28,7 +28,7 @@ private async Task ExecuteAsync(string enterpriseUrl, bool noHelp) // Read accounts from standard input IList accounts = ReadAccounts(); - var viewModel = new SelectAccountViewModel(Context.Environment, accounts) + var viewModel = new SelectAccountViewModel(Context.SessionManager, accounts) { EnterpriseUrl = enterpriseUrl, ShowHelpLink = !noHelp diff --git a/src/shared/GitHub/UI/Commands/TwoFactorCommand.cs b/src/shared/GitHub/UI/Commands/TwoFactorCommand.cs index 6cc87934b..54d4af90c 100644 --- a/src/shared/GitHub/UI/Commands/TwoFactorCommand.cs +++ b/src/shared/GitHub/UI/Commands/TwoFactorCommand.cs @@ -21,7 +21,7 @@ protected TwoFactorCommand(ICommandContext context) private async Task ExecuteAsync(bool sms) { - var viewModel = new TwoFactorViewModel(Context.Environment, Context.ProcessManager) + var viewModel = new TwoFactorViewModel(Context.SessionManager, Context.ProcessManager) { IsSms = sms }; diff --git a/src/shared/GitHub/UI/ViewModels/CredentialsViewModel.cs b/src/shared/GitHub/UI/ViewModels/CredentialsViewModel.cs index 18042431c..2ae32ee60 100644 --- a/src/shared/GitHub/UI/ViewModels/CredentialsViewModel.cs +++ b/src/shared/GitHub/UI/ViewModels/CredentialsViewModel.cs @@ -8,7 +8,7 @@ namespace GitHub.UI.ViewModels { public class CredentialsViewModel : WindowViewModel { - private readonly IEnvironment _environment; + private readonly ISessionManager _sessionManager; private readonly IProcessManager _processManager; private string _enterpriseUrl; @@ -30,12 +30,12 @@ public CredentialsViewModel() // Constructor the XAML designer } - public CredentialsViewModel(IEnvironment environment, IProcessManager processManager) + public CredentialsViewModel(ISessionManager sessionManager, IProcessManager processManager) { - EnsureArgument.NotNull(environment, nameof(environment)); + EnsureArgument.NotNull(sessionManager, nameof(sessionManager)); EnsureArgument.NotNull(processManager, nameof(processManager)); - _environment = environment; + _sessionManager = sessionManager; _processManager = processManager; Title = "Connect to GitHub"; @@ -65,7 +65,7 @@ private void OnPropertyChanged(object sender, PropertyChangedEventArgs e) private void SignUp() { - BrowserUtils.OpenDefaultBrowser(_environment, "https://github.com/pricing"); + _sessionManager.OpenBrowser("https://github.com/pricing"); } private void SignInBrowser() diff --git a/src/shared/GitHub/UI/ViewModels/DeviceCodeViewModel.cs b/src/shared/GitHub/UI/ViewModels/DeviceCodeViewModel.cs index c7c7d1e02..97e1a18fb 100644 --- a/src/shared/GitHub/UI/ViewModels/DeviceCodeViewModel.cs +++ b/src/shared/GitHub/UI/ViewModels/DeviceCodeViewModel.cs @@ -7,7 +7,7 @@ namespace GitHub.UI.ViewModels { public class DeviceCodeViewModel : WindowViewModel { - private readonly IEnvironment _environment; + private readonly ISessionManager _sessionManager; private ICommand _verificationUrlCommand; private string _verificationUrl; @@ -18,11 +18,11 @@ public DeviceCodeViewModel() // Constructor the XAML designer } - public DeviceCodeViewModel(IEnvironment environment) + public DeviceCodeViewModel(ISessionManager sessionManager) { - EnsureArgument.NotNull(environment, nameof(environment)); + EnsureArgument.NotNull(sessionManager, nameof(sessionManager)); - _environment = environment; + _sessionManager = sessionManager; Title = "Device code authentication"; VerificationUrlCommand = new RelayCommand(OpenVerificationUrl); @@ -30,7 +30,7 @@ public DeviceCodeViewModel(IEnvironment environment) private void OpenVerificationUrl() { - BrowserUtils.OpenDefaultBrowser(_environment, VerificationUrl); + _sessionManager.OpenBrowser(VerificationUrl); } public string UserCode diff --git a/src/shared/GitHub/UI/ViewModels/SelectAccountViewModel.cs b/src/shared/GitHub/UI/ViewModels/SelectAccountViewModel.cs index c8a788acf..0d01ffd8b 100644 --- a/src/shared/GitHub/UI/ViewModels/SelectAccountViewModel.cs +++ b/src/shared/GitHub/UI/ViewModels/SelectAccountViewModel.cs @@ -11,7 +11,7 @@ namespace GitHub.UI.ViewModels { public class SelectAccountViewModel : WindowViewModel { - private readonly IEnvironment _environment; + private readonly ISessionManager _sessionManager; private AccountViewModel _selectedAccount; private string _enterpriseUrl; @@ -36,11 +36,11 @@ public SelectAccountViewModel() // Constructor the XAML designer } - public SelectAccountViewModel(IEnvironment environment, IEnumerable accounts = null) + public SelectAccountViewModel(ISessionManager sessionManager, IEnumerable accounts = null) { - EnsureArgument.NotNull(environment, nameof(environment)); + EnsureArgument.NotNull(sessionManager, nameof(sessionManager)); - _environment = environment; + _sessionManager = sessionManager; Title = "Select an account"; ContinueCommand = new RelayCommand(Accept, CanContinue); @@ -69,7 +69,7 @@ private void NewAccount() private void LearnMore() { - BrowserUtils.OpenDefaultBrowser(_environment, Constants.HelpUrls.GcmMultipleUsers); + _sessionManager.OpenBrowser(Constants.HelpUrls.GcmMultipleUsers); } private bool CanContinue() diff --git a/src/shared/GitHub/UI/ViewModels/TwoFactorViewModel.cs b/src/shared/GitHub/UI/ViewModels/TwoFactorViewModel.cs index 7750daf08..5c40573dd 100644 --- a/src/shared/GitHub/UI/ViewModels/TwoFactorViewModel.cs +++ b/src/shared/GitHub/UI/ViewModels/TwoFactorViewModel.cs @@ -8,7 +8,7 @@ namespace GitHub.UI.ViewModels { public class TwoFactorViewModel : WindowViewModel { - private readonly IEnvironment _environment; + private readonly ISessionManager _sessionManager; private readonly IProcessManager _processManager; private string _code; @@ -21,12 +21,12 @@ public TwoFactorViewModel() // Constructor the XAML designer } - public TwoFactorViewModel(IEnvironment environment, IProcessManager processManager) + public TwoFactorViewModel(ISessionManager sessionManager, IProcessManager processManager) { - EnsureArgument.NotNull(environment, nameof(environment)); + EnsureArgument.NotNull(sessionManager, nameof(sessionManager)); EnsureArgument.NotNull(processManager, nameof(processManager)); - _environment = environment; + _sessionManager = sessionManager; _processManager = processManager; Title = "Two-factor authentication required"; @@ -48,7 +48,7 @@ private void OnPropertyChanged(object sender, PropertyChangedEventArgs e) private void LearnMore() { - BrowserUtils.OpenDefaultBrowser(_environment, "https://aka.ms/vs-core-github-auth-help"); + _sessionManager.OpenBrowser("https://aka.ms/vs-core-github-auth-help"); } private bool CanVerify() { diff --git a/src/shared/GitLab/GitLabAuthentication.cs b/src/shared/GitLab/GitLabAuthentication.cs index 8a7ba17bf..df8cdf665 100644 --- a/src/shared/GitLab/GitLabAuthentication.cs +++ b/src/shared/GitLab/GitLabAuthentication.cs @@ -88,7 +88,7 @@ public async Task GetAuthenticationAsync(Uri targetU private async Task GetAuthenticationViaUiAsync( Uri targetUri, string userName, AuthenticationModes modes) { - var viewModel = new CredentialsViewModel(Context.Environment) + var viewModel = new CredentialsViewModel(Context.SessionManager) { ShowBrowserLogin = (modes & AuthenticationModes.Browser) != 0, ShowTokenLogin = (modes & AuthenticationModes.Pat) != 0, @@ -274,7 +274,7 @@ public async Task GetOAuthTokenViaBrowserAsync(Uri targetUri, } var browserOptions = new OAuth2WebBrowserOptions { }; - var browser = new OAuth2SystemWebBrowser(Context.Environment, browserOptions); + var browser = new OAuth2SystemWebBrowser(Context.SessionManager, browserOptions); // Write message to the terminal (if any is attached) for some feedback that we're waiting for a web response Context.Terminal.WriteLine("info: please complete authentication in your browser..."); diff --git a/src/shared/GitLab/UI/Commands/CredentialsCommand.cs b/src/shared/GitLab/UI/Commands/CredentialsCommand.cs index 502f3e0ea..1c1995a8d 100644 --- a/src/shared/GitLab/UI/Commands/CredentialsCommand.cs +++ b/src/shared/GitLab/UI/Commands/CredentialsCommand.cs @@ -37,7 +37,7 @@ protected CredentialsCommand(ICommandContext context) private async Task ExecuteAsync(string userName, string url, bool basic, bool browser, bool pat, bool all) { - var viewModel = new CredentialsViewModel(Context.Environment) + var viewModel = new CredentialsViewModel(Context.SessionManager) { ShowBrowserLogin = all || browser, ShowTokenLogin = all || pat, diff --git a/src/shared/GitLab/UI/ViewModels/CredentialsViewModel.cs b/src/shared/GitLab/UI/ViewModels/CredentialsViewModel.cs index 9731e6879..b1ff4a091 100644 --- a/src/shared/GitLab/UI/ViewModels/CredentialsViewModel.cs +++ b/src/shared/GitLab/UI/ViewModels/CredentialsViewModel.cs @@ -8,7 +8,7 @@ namespace GitLab.UI.ViewModels { public class CredentialsViewModel : WindowViewModel { - private readonly IEnvironment _environment; + private readonly ISessionManager _sessionManager; private string _url; private string _token; @@ -28,11 +28,11 @@ public CredentialsViewModel() // Constructor the XAML designer } - public CredentialsViewModel(IEnvironment environment) + public CredentialsViewModel(ISessionManager sessionManager) { - EnsureArgument.NotNull(environment, nameof(environment)); + EnsureArgument.NotNull(sessionManager, nameof(sessionManager)); - _environment = environment; + _sessionManager = sessionManager; Title = "Connect to GitLab"; SignUpCommand = new RelayCommand(SignUp); @@ -60,7 +60,7 @@ private void OnPropertyChanged(object sender, PropertyChangedEventArgs e) private void SignUp() { - BrowserUtils.OpenDefaultBrowser(_environment, "https://about.gitlab.com/"); + _sessionManager.OpenBrowser("https://about.gitlab.com/"); } private void SignInBrowser() diff --git a/src/shared/TestInfrastructure/Objects/TestSessionManager.cs b/src/shared/TestInfrastructure/Objects/TestSessionManager.cs index 8ac49ebca..dbf891f9c 100644 --- a/src/shared/TestInfrastructure/Objects/TestSessionManager.cs +++ b/src/shared/TestInfrastructure/Objects/TestSessionManager.cs @@ -1,3 +1,4 @@ +using System; namespace GitCredentialManager.Tests.Objects { @@ -7,6 +8,10 @@ public class TestSessionManager : ISessionManager public bool IsDesktopSession { get; set; } + public Action OpenBrowserFunc { get; set; } = _ => { }; + bool ISessionManager.IsWebBrowserAvailable => IsWebBrowserAvailableOverride ?? IsDesktopSession; + + void ISessionManager.OpenBrowser(Uri uri) => OpenBrowserFunc(uri); } }