diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/linux/context-menu-change-password.png b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/linux/context-menu-change-password.png new file mode 100644 index 000000000..48cd1e2df Binary files /dev/null and b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/linux/context-menu-change-password.png differ diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/linux/context-menu-reconnect.png b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/linux/context-menu-reconnect.png new file mode 100644 index 000000000..33f544917 Binary files /dev/null and b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/linux/context-menu-reconnect.png differ diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/dark/context-menu-change-password.png b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/dark/context-menu-change-password.png new file mode 100644 index 000000000..332dbd9ad Binary files /dev/null and b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/dark/context-menu-change-password.png differ diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/dark/context-menu-reconnect.png b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/dark/context-menu-reconnect.png new file mode 100644 index 000000000..8ac6be160 Binary files /dev/null and b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/dark/context-menu-reconnect.png differ diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/light/context-menu-change-password.png b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/light/context-menu-change-password.png new file mode 100644 index 000000000..48cd1e2df Binary files /dev/null and b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/light/context-menu-change-password.png differ diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/light/context-menu-reconnect.png b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/light/context-menu-reconnect.png new file mode 100644 index 000000000..33f544917 Binary files /dev/null and b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/macos/light/context-menu-reconnect.png differ diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/windows/context-menu-change-password.png b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/windows/context-menu-change-password.png new file mode 100644 index 000000000..e5e983c62 Binary files /dev/null and b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/windows/context-menu-change-password.png differ diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/windows/context-menu-reconnect.png b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/windows/context-menu-reconnect.png new file mode 100644 index 000000000..3da009a32 Binary files /dev/null and b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Assets/icons/windows/context-menu-reconnect.png differ diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/AvaloniaRunner.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/AvaloniaRunner.cs index 50d18078f..ba25d8c38 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/AvaloniaRunner.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/AvaloniaRunner.cs @@ -34,7 +34,9 @@ using Avalonia.Logging; using Avalonia.Media.Imaging; using Avalonia.Platform; using Avalonia.Themes.Fluent; +using Avalonia.Styling; using Avalonia.Threading; +using CoCoL; using Duplicati.Library.AutoUpdater; using Duplicati.Library.Logging; @@ -178,6 +180,10 @@ namespace Duplicati.GUI.TrayIcon if (tcs.Task.IsCompletedSuccessfully) { + // Check if we need to show the password prompt + if (Program.NeedsPasswordPrompt) + ShowPasswordPromptAsync(lifetime).FireAndForget(); + actionDelayer.SignalStart(); } else @@ -194,6 +200,42 @@ namespace Duplicati.GUI.TrayIcon } } + private async Task ShowPasswordPromptAsync(IClassicDesktopStyleApplicationLifetime lifetime) + { + try + { + var password = await PasswordPrompt.ShowPasswordDialogAsync(isChangePassword: false).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(password)) + { + RunOnUIThreadInternal(() => + { + try + { + lifetime.Shutdown(); + } + catch { } + }); + return; + } + + // Password received, notify the callback + Program.Connection.UpdatePassword(password); + } + catch (Exception ex) + { + Log.WriteErrorMessage(LOGTAG, "PasswordPromptFailed", ex, "Failed to show password prompt"); + RunOnUIThreadInternal(() => + { + try + { + lifetime.Shutdown(); + } + catch { } + }); + } + } + protected override IMenuItem CreateMenuItem(string text, MenuIcons icon, Action callback, IList subitems) { return new AvaloniaMenuItem(this, text, icon, callback, subitems); @@ -348,6 +390,8 @@ namespace Duplicati.GUI.TrayIcon MenuIcons.Quit => AvaloniaRunner.LoadBitmap("context-menu-quit.png"), MenuIcons.Pause => AvaloniaRunner.LoadBitmap("context-menu-pause.png"), MenuIcons.Resume => AvaloniaRunner.LoadBitmap("context-menu-resume.png"), + MenuIcons.Reconnect => AvaloniaRunner.LoadBitmap("context-menu-reconnect.png"), + MenuIcons.ChangePassword => AvaloniaRunner.LoadBitmap("context-menu-change-password.png"), _ => null }; } @@ -490,6 +534,8 @@ namespace Duplicati.GUI.TrayIcon public override void OnFrameworkInitializationCompleted() { + RequestedThemeVariant = ThemeVariant.Default; + Styles.Add(new FluentTheme()); var icon = AvaloniaRunner.LoadIcon("normal.png"); diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/HttpServerConnection.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/HttpServerConnection.cs index 60c4f0e55..7ed4076dc 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/HttpServerConnection.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/HttpServerConnection.cs @@ -87,8 +87,9 @@ namespace Duplicati.GUI.TrayIcon private record BackgroundRequest(string Method, string Endpoint, string? Body, TimeSpan? Timeout = null); - private readonly string m_apiUri; - private readonly string m_baseUri; + private Uri m_serverUri; + private string m_apiUri; + private string m_baseUri; private string m_password; private string? m_accesstoken; private bool m_isTryingWithPassword; @@ -114,19 +115,45 @@ namespace Duplicati.GUI.TrayIcon private Task m_pollThread; private readonly CancellationTokenSource m_stopToken = new CancellationTokenSource(); - private readonly Dictionary m_options; private readonly Program.PasswordSource m_passwordSource; + private readonly TaskCompletionSource m_passwordUpdatedTcs = new TaskCompletionSource(); public IServerStatus Status { get { return m_status; } } + public void UpdatePassword(string newpassword) + { + m_password = newpassword; + m_accesstoken = null; + m_passwordUpdatedTcs.TrySetResult(newpassword); + } + + public void UpdateServerUri(Uri newServer) + { + if (newServer == null) + throw new ArgumentNullException(nameof(newServer)); + + m_serverUri = newServer; + m_baseUri = Util.AppendDirSeparator(m_serverUri.ToString(), "/"); + m_apiUri = m_baseUri + "api/v1"; + + // Reset state to ensure clean reconnection to the new server + m_lastEventId = 0; + m_lastDataUpdateId = -1; + m_lastNotificationId = -1; + } + private readonly IChannel m_workQueue = Channel.Create(name: "TrayIconRequestQueue"); private readonly CancellationToken _applicationExitEvent; + public Program.PasswordSource PasswordSource => m_passwordSource; + + public Uri ServerUri => m_serverUri; + public HttpServerConnection(IApplicationSettings? applicationSettings, System.Uri server, string password, Program.PasswordSource passwordSource, bool disableTrayIconLogin, string acceptedHostCertificate, Dictionary options) @@ -134,7 +161,8 @@ namespace Duplicati.GUI.TrayIcon _applicationExitEvent = applicationSettings?.ApplicationExit ?? CancellationToken.None; - m_baseUri = Util.AppendDirSeparator(server.ToString(), "/"); + m_serverUri = server; + m_baseUri = Util.AppendDirSeparator(m_serverUri.ToString(), "/"); m_apiUri = m_baseUri + "api/v1"; @@ -143,7 +171,6 @@ namespace Duplicati.GUI.TrayIcon m_firstNotificationTime = DateTime.Now; m_password = password; - m_options = options; m_passwordSource = passwordSource; var acceptedCertificates = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -208,8 +235,11 @@ namespace Duplicati.GUI.TrayIcon }); } - public Task UpdateStatus() - => UpdateStatusAsync(false); + public async Task UpdateStatus() + { + await PasswordAvailableIfNeeded().ConfigureAwait(false); + await UpdateStatusAsync(false).ConfigureAwait(false); + } private async Task UpdateStatusAsync(bool longpoll) { @@ -258,6 +288,15 @@ namespace Duplicati.GUI.TrayIcon m_disableTrayIconLogin = Library.Utility.Utility.ParseBool(str, false); } + private async Task PasswordAvailableIfNeeded() + { + if (string.IsNullOrWhiteSpace(m_password) && m_passwordSource == Program.PasswordSource.SuppliedPassword) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(m_stopToken.Token, _applicationExitEvent); + await m_passwordUpdatedTcs.Task.WaitAsync(cts.Token).ConfigureAwait(false); + } + } + private async Task LongPollRunner() { var started = DateTime.Now; @@ -276,6 +315,8 @@ namespace Duplicati.GUI.TrayIcon await Task.Delay(waitTime, cts.Token).ConfigureAwait(false); } + await PasswordAvailableIfNeeded().ConfigureAwait(false); + started = DateTime.Now; await UpdateStatusAsync(true).ConfigureAwait(false); errorCount = 0; diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordPrompt.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordPrompt.cs new file mode 100644 index 000000000..15f0c3ce5 --- /dev/null +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordPrompt.cs @@ -0,0 +1,234 @@ +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +#nullable enable + +using System; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; + +namespace Duplicati.GUI.TrayIcon; + +internal static class PasswordPrompt +{ + public static bool IsShowingDialog { get; private set; } = false; + /// + /// Shows a password prompt dialog within an already-running Avalonia application. + /// Must be called from the UI thread or will dispatch to it. + /// + /// Whether this is a change password prompt + /// A task that completes with the password, or null if cancelled + public static Task ShowPasswordDialogAsync(bool isChangePassword) + { + if (IsShowingDialog) + throw new InvalidOperationException("A password prompt dialog is already showing."); + + IsShowingDialog = true; + var tcs = new TaskCompletionSource(); + + if (Dispatcher.UIThread.CheckAccess()) + { + ShowDialogInternal(tcs, isChangePassword); + } + else + { + Dispatcher.UIThread.Post(() => ShowDialogInternal(tcs, isChangePassword)); + } + + return tcs.Task.ContinueWith(t => + { + IsShowingDialog = false; + return t.Result; + }); + } + + private static void ShowDialogInternal(TaskCompletionSource tcs, bool isChangePassword) + { + try + { + var window = new PasswordPromptWindow(isChangePassword); + window.PasswordSubmitted += (_, pwd) => + { + tcs.TrySetResult(pwd); + window.Close(); + }; + + window.Cancelled += (_, _) => + { + tcs.TrySetResult(null); + window.Close(); + }; + + window.Closed += (_, _) => + { + // Ensure the task completes even if window is closed another way + tcs.TrySetResult(null); + }; + + window.Show(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to show password prompt window: {ex.Message}"); + tcs.TrySetResult(null); + } + } +} + +internal class PasswordPromptWindow : Window +{ + private readonly TextBox passwordBox; + private readonly TextBox hostUrlBox; + + public event EventHandler? PasswordSubmitted; + + public event EventHandler? Cancelled; + + public PasswordPromptWindow(bool isChangePassword) + { + Title = "Duplicati TrayIcon Connect"; + Width = 420; + Height = 210; + CanResize = false; + WindowStartupLocation = WindowStartupLocation.CenterScreen; + + var description = new TextBlock + { + Text = isChangePassword + ? "Enter the new Duplicati server password:" + : "Enter the Duplicati server password to connect:", + Margin = new Thickness(0, 0, 0, 10), + TextWrapping = TextWrapping.Wrap + }; + + passwordBox = new TextBox + { + Watermark = "Server password", + PasswordChar = '•', + HorizontalAlignment = HorizontalAlignment.Stretch, + Margin = new Thickness(0, 0, 0, 10) + }; + + var hostUrlLabel = new TextBlock + { + Text = "Duplicati server URL:", + Margin = new Thickness(0, 5, 0, 0), + TextWrapping = TextWrapping.Wrap + }; + + hostUrlBox = new TextBox + { + Text = Program.Connection?.ServerUri?.ToString() ?? string.Empty, + Watermark = "http://localhost:8200", + HorizontalAlignment = HorizontalAlignment.Stretch, + Margin = new Thickness(0, 0, 0, 10) + }; + + var okButton = new Button + { + Content = "Connect", + IsDefault = true, + Width = 100 + }; + okButton.Click += (_, _) => SubmitPassword(); + + var cancelButton = new Button + { + Content = "Cancel", + IsCancel = true, + Width = 100 + }; + cancelButton.Click += (_, _) => Cancel(); + + var buttonPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 10 + }; + buttonPanel.Children.Add(cancelButton); + buttonPanel.Children.Add(okButton); + + var contentPanel = new Grid + { + RowDefinitions = new RowDefinitions("*,Auto") + }; + + var inputPanel = new StackPanel + { + Children = + { + description, + passwordBox, + hostUrlLabel, + hostUrlBox + } + }; + + Grid.SetRow(inputPanel, 0); + Grid.SetRow(buttonPanel, 1); + + contentPanel.Children.Add(inputPanel); + contentPanel.Children.Add(buttonPanel); + + Content = new Border + { + Padding = new Thickness(20), + Child = contentPanel + }; + + Opened += (_, _) => passwordBox.Focus(); + } + + private void SubmitPassword() + { + var password = passwordBox.Text; + var hostUrlText = hostUrlBox.Text?.Trim(); + + if (!string.IsNullOrWhiteSpace(hostUrlText) && Program.Connection != null) + { + try + { + var uri = new Uri(hostUrlText, UriKind.Absolute); + + if (!uri.Equals(Program.Connection.ServerUri)) + { + Program.Connection.UpdateServerUri(uri); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Invalid host URL '{hostUrlText}': {ex.Message}"); + } + } + + PasswordSubmitted?.Invoke(this, password); + } + + private void Cancel() + { + Cancelled?.Invoke(this, EventArgs.Empty); + } +} diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs index 277219b8e..399efa94f 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs @@ -1,362 +1,358 @@ -// Copyright (C) 2025, The Duplicati Team -// https://duplicati.com, hello@duplicati.com -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading; -using CoCoL; -using Duplicati.CommandLine; -using Duplicati.Library.AutoUpdater; -using Duplicati.Library.Interface; -using Duplicati.Library.Utility; -using Duplicati.Server; -using Duplicati.Server.Database; -using Duplicati.WebserverCore.Services; -using Microsoft.Extensions.DependencyInjection; -using Uri = System.Uri; - -namespace Duplicati.GUI.TrayIcon -{ - public static class Program - { - /// - /// The source of the password - /// - public enum PasswordSource - { - /// - /// No password, using token information from the database - /// - Database, - /// - /// No password supplied, using the hosted server - /// - HostedServer, - /// - /// The password was supplied on the commandline - /// - SuppliedPassword - } - - public static HttpServerConnection Connection; - - private const string HOSTURL_OPTION = "hosturl"; - private const string NOHOSTEDSERVER_OPTION = "no-hosted-server"; - private const string READCONFIGFROMDB_OPTION = "read-config-from-db"; - private const string ACCEPTED_SSL_CERTIFICATE = "host-cert-hash"; - - private const string DETACHED_PROCESS = "detached-process"; - private const string BROWSER_COMMAND_OPTION = "browser-command"; - private static readonly IReadOnlySet DETATCHED_WEBERVER_OPTIONS = new HashSet(StringComparer.OrdinalIgnoreCase) - { - WebServerLoader.OPTION_WEBSERVICE_PASSWORD, - WebServerLoader.OPTION_PORT, - DataFolderManager.SERVER_DATAFOLDER_OPTION, - DataFolderManager.PORTABLE_MODE_OPTION - }; - - - private static string DEFAULT_HOSTURL => $"http://{Utility.IpVersionCompatibleLoopback}:8200"; - - private static string _browser_command = null; - private static bool disableTrayIconLogin = false; - private static bool openui = false; - private static Uri serverURL = new(DEFAULT_HOSTURL); - public static string BrowserCommand { get { return _browser_command; } } - public static Server.Database.Connection databaseConnection = null; - - /// - /// The main entry point for the application. - /// - [STAThread] - public static int Main(string[] _args) - { - PreloadSettingsLoader.ConfigurePreloadSettings(ref _args, PackageHelper.NamedExecutable.TrayIcon); - var args = new List(_args); - var options = CommandLineParser.ExtractOptions(args); - - if (OperatingSystem.IsWindows() && !Utility.ParseBoolOption(options, DETACHED_PROCESS)) - Win32.AttachConsole(Win32.ATTACH_PARENT_PROCESS); - - if (HelpOptionExtensions.IsArgumentAnyHelpString(_args)) - { - Console.WriteLine("Supported commandline arguments:"); - Console.WriteLine(); - - foreach (ICommandLineArgument arg in AllSupportedCommands) - Console.WriteLine("--{0}: {1}", arg.Name, arg.LongDescription); - - Console.WriteLine("Additionally, these server options are also supported:"); - Console.WriteLine(); - - foreach (ICommandLineArgument arg in WebserverSupportedCommands) - Console.WriteLine("--{0}: {1}", arg.Name, arg.LongDescription); - - return 0; - } - - // Apply secret provider, if any, so password etc can be retrieved - Library.Main.SecretProviderHelper.ApplySecretProviderAsync([], [], options, TempFolder.SystemTempPath, null, CancellationToken.None) - .Await(); - - options.TryGetValue(BROWSER_COMMAND_OPTION, out _browser_command); - - HostedInstanceKeeper hosted = null; - - string password = null; - var passwordSource = PasswordSource.SuppliedPassword; - var acceptedHostCertificate = options.GetValueOrDefault(ACCEPTED_SSL_CERTIFICATE, null); - var detached = Utility.ParseBoolOption(options, NOHOSTEDSERVER_OPTION); - - var supportedCommands = BasicSupportedCommands.AsEnumerable(); - if (detached) - { - supportedCommands = DetachSupportedCommands - .Concat(supportedCommands); - } - else - { - supportedCommands = supportedCommands.Concat(WebserverSupportedCommands); - } - - // Validate options, and log to console - using (var logger = new ConsoleOutput(Console.Out, options)) - CommandLineArgumentValidator.ValidateArguments(supportedCommands, options, Server.Program.KnownDuplicateOptions, new HashSet()); - - if (!detached) - { - try - { - // Tell the hosted server it was started by the TrayIcon - var applicationSettings = new ApplicationSettings - { - Origin = "Tray icon" - }; - passwordSource = PasswordSource.HostedServer; - // Ignore TrayIcon specific settings - foreach (var c in BasicSupportedCommands.Select(x => x.Name)) - Server.Program.ValidationIgnoredOptions.Add(c); - hosted = new HostedInstanceKeeper(applicationSettings, _args); - } - catch (Exception ex) - { - if (ex.InnerException != null && ex.InnerException is Server.SingleInstance.MultipleInstanceException) - { - Console.WriteLine(Server.Strings.Program.AnotherInstanceDetected); - return 1; - } - else - throw; - } - - // We have a hosted server, if this is the first run, - // we should open the main page - var connection = Server.Program.DuplicatiWebserver.Provider.GetRequiredService(); - openui = connection.ApplicationSettings.IsFirstRun || connection.ApplicationSettings.ServerPortChanged; - - var scheme = connection.ApplicationSettings.UseHTTPS ? "https" : "http"; - serverURL = new UriBuilder(serverURL) - { - Port = Server.Program.DuplicatiWebserver.Port, - Scheme = scheme - }.Uri; - - if (connection.ApplicationSettings.UseHTTPS && string.IsNullOrWhiteSpace(acceptedHostCertificate)) - acceptedHostCertificate = connection.ApplicationSettings.ServerSSLCertificate?.FirstOrDefault(x => x.HasPrivateKey)?.GetCertHashString(); - - } - else if (Utility.ParseBoolOption(options, READCONFIGFROMDB_OPTION)) - { - if (File.Exists(Path.Combine(DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ReadWritePermissionSet), DataFolderManager.SERVER_DATABASE_FILENAME))) - { - passwordSource = PasswordSource.Database; - databaseConnection = Server.Program.GetDatabaseConnection(new ApplicationSettings(), options, true, false); - - if (databaseConnection != null) - { - disableTrayIconLogin = databaseConnection.ApplicationSettings.DisableTrayIconLogin; - if (databaseConnection.ApplicationSettings.UseHTTPS && string.IsNullOrWhiteSpace(acceptedHostCertificate)) - acceptedHostCertificate = databaseConnection.ApplicationSettings.ServerSSLCertificate?.FirstOrDefault(x => x.HasPrivateKey)?.GetCertHashString(); - - var scheme = databaseConnection.ApplicationSettings.UseHTTPS ? "https" : "http"; - serverURL = new UriBuilder(serverURL) - { - Port = databaseConnection.ApplicationSettings.LastWebserverPort == -1 ? serverURL.Port : databaseConnection.ApplicationSettings.LastWebserverPort, - Scheme = scheme - }.Uri; - } - } - } - - // Legacy undocumented way to provide the password - if (options.TryGetValue("webserver-password", out var pwd)) - password = pwd; - if (options.TryGetValue(WebServerLoader.OPTION_WEBSERVICE_PASSWORD, out pwd)) - password = pwd; - - // Let the user specify the port, if they are not providing a hosturl - if (!options.ContainsKey(HOSTURL_OPTION) && options.TryGetValue(WebServerLoader.OPTION_PORT, out var portString) && int.TryParse(portString, out var port)) - serverURL = new UriBuilder(serverURL) { Port = port }.Uri; - - if (options.TryGetValue(HOSTURL_OPTION, out var url)) - serverURL = new Uri(url); - - if (string.IsNullOrWhiteSpace(password) && passwordSource == PasswordSource.SuppliedPassword) - { - Console.WriteLine($@" -When running the TrayIcon without a hosted server, you must provide the server password via the option --{WebServerLoader.OPTION_WEBSERVICE_PASSWORD}=. -If the TrayIcon instance has read access to the server database, you can also or use the option --{READCONFIGFROMDB_OPTION}, possibly with --server-datafolder=. - -No password provided, unable to connect to server, exiting"); - return 1; - } - - StartTray(_args, options, hosted, passwordSource, password, acceptedHostCertificate); - - return 0; - } - - private static void StartTray(string[] _args, Dictionary options, HostedInstanceKeeper hosted, PasswordSource passwordSource, string password, string acceptedHostCertificate) - { - using (hosted) - { - var reSpawn = 0; - - do - { - if (reSpawn > 0) - Thread.Sleep(1000); - - try - { - using (Connection = new HttpServerConnection(hosted?.applicationSettings, serverURL, password, passwordSource, disableTrayIconLogin, acceptedHostCertificate, options)) - { - // Make sure we have the latest status, but don't care if it fails - Connection.UpdateStatus().FireAndForget(); - - using (var tk = RunTrayIcon()) - { - if (Server.Program.ApplicationInstance != null) - Server.Program.ApplicationInstance.SecondInstanceDetected += - x => tk.ShowStatusWindow(); - - // TODO: If we change to hosted browser this should be a callback - if (openui) - { - Connection.GetStatusWindowURLAsync().ContinueWith(t => - { - if (t.IsFaulted) - { - Console.WriteLine("Failed to get status window URL: " + t.Exception.Message); - tk.NotifyUser("Failed to get status window URL", t.Exception.Message, NotificationType.Error); - return; - } - - tk.ShowUrlInWindow(t.Result); - var connection = Server.Program.DuplicatiWebserver.Provider.GetRequiredService(); - connection.ApplicationSettings.IsFirstRun = false; - connection.ApplicationSettings.ServerPortChanged = false; - - }); - } - - // If the server shuts down, shut down the tray-icon as well - Action shutdownEvent = () => - { - // Make sure we do not start again after - // a controlled exit - reSpawn = 100; - tk.InvokeExit(); - }; - - Connection.ConnectionClosed = shutdownEvent; - if (hosted != null) - hosted.InstanceShutdown = shutdownEvent; - - tk.Init(_args); - - // If the tray-icon quits, stop the server - reSpawn = 100; - - // Make sure that the server shutdown does not access the tray-icon, - // as it would be disposed by now - if (hosted != null) - hosted.InstanceShutdown = null; - Connection.ConnectionClosed = null; - - } - } - - } - catch (HttpRequestException ex) - { - System.Diagnostics.Trace.WriteLine("Request error: " + ex); - Console.WriteLine("Request error: " + ex); - - reSpawn++; - } - catch (Exception ex) - { - System.Diagnostics.Trace.WriteLine("Unexpected error: " + ex); - Console.WriteLine("Unexpected error: " + ex); - return; - } - } while (reSpawn < 3); - } - } - - private static TrayIconBase RunTrayIcon() - => new AvaloniaRunner(); - - public static ICommandLineArgument[] DetachSupportedCommands => - [ - new CommandLineArgument(NOHOSTEDSERVER_OPTION, CommandLineArgument.ArgumentType.String, "Disables local server", "Set this option to not spawn a local service, use if the TrayIcon should connect to a running service"), - new CommandLineArgument(READCONFIGFROMDB_OPTION, CommandLineArgument.ArgumentType.String, "Read server connection info from DB", $"Set this option to read server connection info for running service from its database (only together with {NOHOSTEDSERVER_OPTION})"), - .. WebserverSupportedCommands.Where(x => DETATCHED_WEBERVER_OPTIONS.Contains(x.Name)) - ]; - - public static ICommandLineArgument[] BasicSupportedCommands => - [ - new CommandLineArgument(HOSTURL_OPTION, CommandLineArgument.ArgumentType.String, "Selects the url to connect to", "Supply the url that the TrayIcon will connect to and show status for", DEFAULT_HOSTURL), - new CommandLineArgument(BROWSER_COMMAND_OPTION, CommandLineArgument.ArgumentType.String, "Sets the browser command", "Set this option to override the default browser detection"), - new CommandLineArgument(ACCEPTED_SSL_CERTIFICATE, CommandLineArgument.ArgumentType.String, "Accepts a specific SSL certificate", "Set this option to accept a specific SSL certificate, the value should be the hash of the certificate in hexadecimal format. Use * to accept any certificate (dangerous)"), - .. WindowsSupportedCommands - ]; - - private static ICommandLineArgument[] WindowsSupportedCommands => - OperatingSystem.IsWindows() - ? [new CommandLineArgument(DETACHED_PROCESS, CommandLineArgument.ArgumentType.String, "Runs the tray-icon detached", "This option runs the tray-icon in detached mode, meaning that the process will exit immediately and not send output to the console of the caller")] - : []; - - public static ICommandLineArgument[] WebserverSupportedCommands - => Server.Program.SupportedCommands; - - public static ICommandLineArgument[] AllSupportedCommands => - [ - .. DetachSupportedCommands, - .. BasicSupportedCommands - ]; - } -} +// Copyright (C) 2025, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using CoCoL; +using Duplicati.CommandLine; +using Duplicati.Library.AutoUpdater; +using Duplicati.Library.Interface; +using Duplicati.Library.Utility; +using Duplicati.Server; +using Duplicati.Server.Database; +using Duplicati.WebserverCore.Services; +using Microsoft.Extensions.DependencyInjection; +using Uri = System.Uri; + +namespace Duplicati.GUI.TrayIcon +{ + public static class Program + { + /// + /// The source of the password + /// + public enum PasswordSource + { + /// + /// No password, using token information from the database + /// + Database, + /// + /// No password supplied, using the hosted server + /// + HostedServer, + /// + /// The password was supplied on the commandline + /// + SuppliedPassword + } + + public static HttpServerConnection Connection; + + /// + /// Indicates that a password is needed and should be requested via GUI after Avalonia starts + /// + public static bool NeedsPasswordPrompt { get; private set; } + + private const string HOSTURL_OPTION = "hosturl"; + private const string NOHOSTEDSERVER_OPTION = "no-hosted-server"; + private const string READCONFIGFROMDB_OPTION = "read-config-from-db"; + private const string ACCEPTED_SSL_CERTIFICATE = "host-cert-hash"; + + private const string DETACHED_PROCESS = "detached-process"; + private const string BROWSER_COMMAND_OPTION = "browser-command"; + private static readonly IReadOnlySet DETATCHED_WEBERVER_OPTIONS = new HashSet(StringComparer.OrdinalIgnoreCase) + { + WebServerLoader.OPTION_WEBSERVICE_PASSWORD, + WebServerLoader.OPTION_PORT, + DataFolderManager.SERVER_DATAFOLDER_OPTION, + DataFolderManager.PORTABLE_MODE_OPTION + }; + + + private static string DEFAULT_HOSTURL => $"http://{Utility.IpVersionCompatibleLoopback}:8200"; + + private static string _browser_command = null; + private static bool disableTrayIconLogin = false; + private static bool openui = false; + private static Uri serverURL = new(DEFAULT_HOSTURL); + public static string BrowserCommand { get { return _browser_command; } } + public static Server.Database.Connection databaseConnection = null; + + /// + /// The main entry point for the application. + /// + [STAThread] + public static int Main(string[] _args) + { + PreloadSettingsLoader.ConfigurePreloadSettings(ref _args, PackageHelper.NamedExecutable.TrayIcon); + var args = new List(_args); + var options = CommandLineParser.ExtractOptions(args); + + if (OperatingSystem.IsWindows() && !Utility.ParseBoolOption(options, DETACHED_PROCESS)) + Win32.AttachConsole(Win32.ATTACH_PARENT_PROCESS); + + if (HelpOptionExtensions.IsArgumentAnyHelpString(_args)) + { + Console.WriteLine("Supported commandline arguments:"); + Console.WriteLine(); + + foreach (ICommandLineArgument arg in AllSupportedCommands) + Console.WriteLine("--{0}: {1}", arg.Name, arg.LongDescription); + + Console.WriteLine("Additionally, these server options are also supported:"); + Console.WriteLine(); + + foreach (ICommandLineArgument arg in WebserverSupportedCommands) + Console.WriteLine("--{0}: {1}", arg.Name, arg.LongDescription); + + return 0; + } + + // Apply secret provider, if any, so password etc can be retrieved + Library.Main.SecretProviderHelper.ApplySecretProviderAsync([], [], options, TempFolder.SystemTempPath, null, CancellationToken.None) + .Await(); + + options.TryGetValue(BROWSER_COMMAND_OPTION, out _browser_command); + + HostedInstanceKeeper hosted = null; + + string password = null; + var passwordSource = PasswordSource.SuppliedPassword; + var acceptedHostCertificate = options.GetValueOrDefault(ACCEPTED_SSL_CERTIFICATE, null); + var detached = Utility.ParseBoolOption(options, NOHOSTEDSERVER_OPTION); + + var supportedCommands = BasicSupportedCommands.AsEnumerable(); + if (detached) + { + supportedCommands = DetachSupportedCommands + .Concat(supportedCommands); + } + else + { + supportedCommands = supportedCommands.Concat(WebserverSupportedCommands); + } + + // Validate options, and log to console + using (var logger = new ConsoleOutput(Console.Out, options)) + CommandLineArgumentValidator.ValidateArguments(supportedCommands, options, Server.Program.KnownDuplicateOptions, new HashSet()); + + if (!detached) + { + try + { + // Tell the hosted server it was started by the TrayIcon + var applicationSettings = new ApplicationSettings + { + Origin = "Tray icon" + }; + passwordSource = PasswordSource.HostedServer; + // Ignore TrayIcon specific settings + foreach (var c in BasicSupportedCommands.Select(x => x.Name)) + Server.Program.ValidationIgnoredOptions.Add(c); + hosted = new HostedInstanceKeeper(applicationSettings, _args); + } + catch (Exception ex) + { + if (ex.InnerException != null && ex.InnerException is Server.SingleInstance.MultipleInstanceException) + { + Console.WriteLine(Server.Strings.Program.AnotherInstanceDetected); + return 1; + } + else + throw; + } + + // We have a hosted server, if this is the first run, + // we should open the main page + var connection = Server.Program.DuplicatiWebserver.Provider.GetRequiredService(); + openui = connection.ApplicationSettings.IsFirstRun || connection.ApplicationSettings.ServerPortChanged; + + var scheme = connection.ApplicationSettings.UseHTTPS ? "https" : "http"; + serverURL = new UriBuilder(serverURL) + { + Port = Server.Program.DuplicatiWebserver.Port, + Scheme = scheme + }.Uri; + + if (connection.ApplicationSettings.UseHTTPS && string.IsNullOrWhiteSpace(acceptedHostCertificate)) + acceptedHostCertificate = connection.ApplicationSettings.ServerSSLCertificate?.FirstOrDefault(x => x.HasPrivateKey)?.GetCertHashString(); + + } + else if (Utility.ParseBoolOption(options, READCONFIGFROMDB_OPTION)) + { + if (File.Exists(Path.Combine(DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ReadWritePermissionSet), DataFolderManager.SERVER_DATABASE_FILENAME))) + { + passwordSource = PasswordSource.Database; + databaseConnection = Server.Program.GetDatabaseConnection(new ApplicationSettings(), options, true, false); + + if (databaseConnection != null) + { + disableTrayIconLogin = databaseConnection.ApplicationSettings.DisableTrayIconLogin; + if (databaseConnection.ApplicationSettings.UseHTTPS && string.IsNullOrWhiteSpace(acceptedHostCertificate)) + acceptedHostCertificate = databaseConnection.ApplicationSettings.ServerSSLCertificate?.FirstOrDefault(x => x.HasPrivateKey)?.GetCertHashString(); + + var scheme = databaseConnection.ApplicationSettings.UseHTTPS ? "https" : "http"; + serverURL = new UriBuilder(serverURL) + { + Port = databaseConnection.ApplicationSettings.LastWebserverPort == -1 ? serverURL.Port : databaseConnection.ApplicationSettings.LastWebserverPort, + Scheme = scheme + }.Uri; + } + } + } + + // Legacy undocumented way to provide the password + if (options.TryGetValue("webserver-password", out var pwd)) + password = pwd; + if (options.TryGetValue(WebServerLoader.OPTION_WEBSERVICE_PASSWORD, out pwd)) + password = pwd; + + // Let the user specify the port, if they are not providing a hosturl + if (!options.ContainsKey(HOSTURL_OPTION) && options.TryGetValue(WebServerLoader.OPTION_PORT, out var portString) && int.TryParse(portString, out var port)) + serverURL = new UriBuilder(serverURL) { Port = port }.Uri; + + if (options.TryGetValue(HOSTURL_OPTION, out var url)) + serverURL = new Uri(url); + + if (string.IsNullOrWhiteSpace(password) && passwordSource == PasswordSource.SuppliedPassword) + NeedsPasswordPrompt = true; + + StartTray(_args, options, hosted, passwordSource, password, acceptedHostCertificate); + + return 0; + } + + private static void StartTray(string[] _args, Dictionary options, HostedInstanceKeeper hosted, PasswordSource passwordSource, string password, string acceptedHostCertificate) + { + using (hosted) + { + var reSpawn = 0; + + do + { + if (reSpawn > 0) + Thread.Sleep(1000); + + try + { + using (Connection = new HttpServerConnection(hosted?.applicationSettings, serverURL, password, passwordSource, disableTrayIconLogin, acceptedHostCertificate, options)) + { + // Make sure we have the latest status, but don't care if it fails + Connection.UpdateStatus().FireAndForget(); + using (var tk = RunTrayIcon()) + { + if (Server.Program.ApplicationInstance != null) + Server.Program.ApplicationInstance.SecondInstanceDetected += + x => tk.ShowStatusWindow(); + + // TODO: If we change to hosted browser this should be a callback + if (openui) + { + Connection.GetStatusWindowURLAsync().ContinueWith(t => + { + if (t.IsFaulted) + { + Console.WriteLine("Failed to get status window URL: " + t.Exception.Message); + tk.NotifyUser("Failed to get status window URL", t.Exception.Message, NotificationType.Error); + return; + } + + tk.ShowUrlInWindow(t.Result); + var connection = Server.Program.DuplicatiWebserver.Provider.GetRequiredService(); + connection.ApplicationSettings.IsFirstRun = false; + connection.ApplicationSettings.ServerPortChanged = false; + + }); + } + + // If the server shuts down, shut down the tray-icon as well + Action shutdownEvent = () => + { + // Make sure we do not start again after + // a controlled exit + reSpawn = 100; + tk.InvokeExit(); + }; + + Connection.ConnectionClosed = shutdownEvent; + if (hosted != null) + hosted.InstanceShutdown = shutdownEvent; + + tk.Init(_args); + + // If the tray-icon quits, stop the server + reSpawn = 100; + + // Make sure that the server shutdown does not access the tray-icon, + // as it would be disposed by now + if (hosted != null) + hosted.InstanceShutdown = null; + Connection.ConnectionClosed = null; + + } + } + + } + catch (HttpRequestException ex) + { + System.Diagnostics.Trace.WriteLine("Request error: " + ex); + Console.WriteLine("Request error: " + ex); + + reSpawn++; + } + catch (Exception ex) + { + System.Diagnostics.Trace.WriteLine("Unexpected error: " + ex); + Console.WriteLine("Unexpected error: " + ex); + return; + } + } while (reSpawn < 3); + } + } + + private static TrayIconBase RunTrayIcon() + => new AvaloniaRunner(); + + public static ICommandLineArgument[] DetachSupportedCommands => + [ + new CommandLineArgument(NOHOSTEDSERVER_OPTION, CommandLineArgument.ArgumentType.String, "Disables local server", "Set this option to not spawn a local service, use if the TrayIcon should connect to a running service"), + new CommandLineArgument(READCONFIGFROMDB_OPTION, CommandLineArgument.ArgumentType.String, "Read server connection info from DB", $"Set this option to read server connection info for running service from its database (only together with {NOHOSTEDSERVER_OPTION})"), + .. WebserverSupportedCommands.Where(x => DETATCHED_WEBERVER_OPTIONS.Contains(x.Name)) + ]; + + public static ICommandLineArgument[] BasicSupportedCommands => + [ + new CommandLineArgument(HOSTURL_OPTION, CommandLineArgument.ArgumentType.String, "Selects the url to connect to", "Supply the url that the TrayIcon will connect to and show status for", DEFAULT_HOSTURL), + new CommandLineArgument(BROWSER_COMMAND_OPTION, CommandLineArgument.ArgumentType.String, "Sets the browser command", "Set this option to override the default browser detection"), + new CommandLineArgument(ACCEPTED_SSL_CERTIFICATE, CommandLineArgument.ArgumentType.String, "Accepts a specific SSL certificate", "Set this option to accept a specific SSL certificate, the value should be the hash of the certificate in hexadecimal format. Use * to accept any certificate (dangerous)"), + .. WindowsSupportedCommands + ]; + + private static ICommandLineArgument[] WindowsSupportedCommands => + OperatingSystem.IsWindows() + ? [new CommandLineArgument(DETACHED_PROCESS, CommandLineArgument.ArgumentType.String, "Runs the tray-icon detached", "This option runs the tray-icon in detached mode, meaning that the process will exit immediately and not send output to the console of the caller")] + : []; + + public static ICommandLineArgument[] WebserverSupportedCommands + => Server.Program.SupportedCommands; + + public static ICommandLineArgument[] AllSupportedCommands => + [ + .. DetachSupportedCommands, + .. BasicSupportedCommands + ]; + } +} diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs index 206b1a3fe..5110f2a63 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs @@ -24,6 +24,7 @@ using Duplicati.Server.Serialization; using Duplicati.Server.Serialization.Interface; using System.Collections.Generic; using System.Threading.Tasks; +using CoCoL; namespace Duplicati.GUI.TrayIcon { @@ -34,6 +35,8 @@ namespace Duplicati.GUI.TrayIcon Quit, Pause, Resume, + Reconnect, + ChangePassword } public enum TrayIcons @@ -73,6 +76,7 @@ namespace Duplicati.GUI.TrayIcon public abstract class TrayIconBase : IDisposable { protected IMenuItem m_reconnectMenu; + protected IMenuItem m_changePasswordMenu; protected IMenuItem m_openMenu; protected IMenuItem m_pauseMenu; protected IMenuItem m_quitMenu; @@ -161,18 +165,41 @@ namespace Duplicati.GUI.TrayIcon protected IEnumerable BuildMenu() { - m_reconnectMenu = CreateMenuItem("Reconnect", MenuIcons.Resume, Reconnect, null); + m_reconnectMenu = CreateMenuItem("Reconnect", MenuIcons.Reconnect, Reconnect, null); m_reconnectMenu.SetHidden(true); + m_changePasswordMenu = CreateMenuItem("Change Password", MenuIcons.ChangePassword, OnChangePasswordClicked, null); + m_changePasswordMenu.SetHidden(true); m_openMenu = CreateMenuItem("Open", MenuIcons.Status, OnStatusClicked, null); m_openMenu.SetDefault(true); return [ m_reconnectMenu, + m_changePasswordMenu, m_openMenu, m_pauseMenu = CreateMenuItem("Pause", MenuIcons.Pause, OnPauseClicked, null ), m_quitMenu = CreateMenuItem("Quit", MenuIcons.Quit, OnQuitClicked, null), ]; } + private void OnChangePasswordClicked() + { + PasswordPrompt.ShowPasswordDialogAsync(isChangePassword: true) + .ContinueWith(t => + { + OnStatusUpdated(Program.Connection.Status); + if (t.IsFaulted) + return; + + if (string.IsNullOrWhiteSpace(t.Result)) + return; + + Program.Connection.UpdatePassword(t.Result); + }) + .FireAndForget(); + + OnStatusUpdated(Program.Connection.Status); + + } + private void Reconnect() { Program.Connection.UpdateStatus().ContinueWith(t => @@ -269,7 +296,8 @@ namespace Duplicati.GUI.TrayIcon m_openMenu.SetHidden(status.SuggestedStatusIcon == SuggestedStatusIcon.Disconnected); m_pauseMenu.SetHidden(status.SuggestedStatusIcon == SuggestedStatusIcon.Disconnected); - m_reconnectMenu.SetHidden(status.SuggestedStatusIcon != SuggestedStatusIcon.Disconnected); + m_reconnectMenu.SetHidden(status.SuggestedStatusIcon != SuggestedStatusIcon.Disconnected || PasswordPrompt.IsShowingDialog); + m_changePasswordMenu.SetHidden(status.SuggestedStatusIcon != SuggestedStatusIcon.Disconnected || Program.Connection?.PasswordSource != Program.PasswordSource.SuppliedPassword || PasswordPrompt.IsShowingDialog); }); #region IDisposable implementation